diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md
index 788600b..f114174 100644
--- a/docs/COMPONENTS.md
+++ b/docs/COMPONENTS.md
@@ -112,6 +112,11 @@ 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) |
+| Resizable | Drag a divider | Dividers are also tab stops: the arrow keys move them 16px; the pointer shows a resize cursor |
+| Navbar | Links and the menu button react to taps | They are also tab stops (Enter / Space); children are collected in `Items` |
+| Sidebar | Width animates on collapse | Same, with items as tab stops; children are collected in `Groups` and `Items` |
+| DateRangePicker | Tap the trigger | Also Enter / Space / Down on the focused trigger; Escape closes the panel |
+| FileUpload | `Files` are `FileResult`; `FileTypes` is a `FilePickerFileType`; drop works on Windows only | `Files` are `IStorageFile`; `FileTypes` is a list of `FilePickerFileType` (e.g. `FilePickerFileTypes.ImageAll`); drop works wherever Avalonia supports it; Enter / Space open the picker |
| ToggleGroup | Items toggle on tap | Items are also tab stops (Enter / Space); children are collected in `Items` |
| NumberInput | Numeric keyboard | Up / Down step the value while the field has focus |
| TagInput | Enter, comma or semicolon adds a tag | Backspace in the empty field also removes the last tag |
@@ -488,6 +493,61 @@ shellui-native add date-picker # also installs calendar
```
+### DateRangePicker
+A DatePicker-style trigger that opens one calendar for a start and an end date. The first pick
+is the start, the second the end; a pick before the start, or one made when the range is
+already complete, starts over. The panel closes when the range is complete, and the × clears
+it.
+
+```bash
+shellui-native add date-range-picker
+```
+
+**Properties:** `StartDate`, `EndDate` (two-way, nullable), `Placeholder` ("Pick a date
+range"), `Format` ("MMM d, yyyy"), `AllowClear` (true), `MinimumDate`, `MaximumDate` ·
+**Events:** `RangeChanged` · **Methods:** `SetOpen(bool)`, `Close()`, `Clear()`
+
+```xml
+
+```
+
+It uses `Calendar`'s `RangeStart` and `RangeEnd`, which tint a span of days; you can set them
+on a `Calendar` of your own too.
+
+---
+
+### FileUpload
+A dashed drop zone with an upload icon and a prompt. Tapping it opens the platform file picker;
+the picked files are listed underneath, each with a × to remove it. On Windows, files can also
+be dropped on it.
+
+```bash
+shellui-native add file-upload
+```
+
+**Properties:** `Text`, `Multiple` (false: a new pick replaces the file), `MaxFiles` (0 = no
+limit; applies when `Multiple` is on), `FileTypes` (`FilePickerFileType`, e.g.
+`FilePickerFileType.Images`; null offers every file), `ShowFiles` (true), `Files` (read-only) ·
+**Events:** `FilesChanged` · **Methods:** `PickAsync()`, `Add(files)`, `Remove(file)`,
+`Clear()`
+
+```xml
+
+```
+
+```csharp
+private async void OnFiles(object? sender, IReadOnlyList files)
+{
+ foreach (var file in files)
+ {
+ await using var stream = await file.OpenReadAsync();
+ // upload or read the stream
+ }
+}
+```
+
+---
+
### TimePicker
Custom-drawn: a 40px trigger (clock icon + formatted time) that opens scrollable hour and minute
columns — plus AM/PM on a 12-hour clock — floating over the page. Picking a value updates `Time`
@@ -569,6 +629,30 @@ shellui-native add wrap-layout
Prefer it to `FlexLayout` for simple wrapping: it measures each child once per layout pass.
+### Resizable
+Panels side by side (or stacked) with a hairline between them. Dragging the hairline moves
+space from one neighbor to the other; neither goes under its `MinSize`. Groups nest, so a panel
+can hold another `Resizable` with the other orientation. Give the group a size.
+
+```bash
+shellui-native add resizable
+```
+
+**Properties (Resizable):** `Orientation` (Horizontal), `ShowHandle` (true: a short bar on each
+divider) · **Events:** `Resized` (a drag ended)
+
+**Properties (ResizablePanel):** `Size` (two-way; its share of the space, 2 is twice as much as
+1), `MinSize` (48) · its child view is the panel's content
+
+```xml
+
+ ...
+ ...
+
+```
+
+---
+
### Card
`rounded-xl border bg-card shadow-sm` container.
@@ -662,6 +746,68 @@ shellui-native add accordion
## Navigation Components
+### Navbar
+Top bar (56px, border below): a brand, links and actions at the end. Narrower than
+`CollapseBelow`, the links move into a menu that a button opens under the bar; picking a link
+closes it.
+
+```bash
+shellui-native add navbar
+```
+
+**Properties (Navbar):** `Title`, `Icon`, `Brand` (a view that replaces the icon and title),
+`Actions` (a view at the end of the bar), `SelectedValue` (two-way), `CollapseBelow` (640; 0
+never collapses), `IsMenuOpen` (two-way) · **Events:** `SelectedValueChanged`
+
+**Properties (NavbarItem):** `Text`, `Value`, `Icon`, `IsEnabled` · **Events:** `Clicked`
+
+```xml
+
+
+
+
+
+
+
+```
+
+---
+
+### Sidebar
+App sidebar: a 256px column with a header, scrolling groups of items and a footer. Collapsed,
+it is a 56px rail that shows the icons only. Put it in a column next to your content (or
+inside a `Sheet` on phones).
+
+```bash
+shellui-native add sidebar
+```
+
+**Properties (Sidebar):** `IsCollapsed` (two-way), `SelectedValue` (two-way), `ExpandedWidth`
+(256), `CollapsedWidth` (56), `Side` (Left, Right: the border is drawn on the inner edge),
+`Header`, `Footer` · **Events:** `IsCollapsedChanged`, `SelectedValueChanged` · **Methods:**
+`Toggle()`
+
+**SidebarGroup:** `Label` · **SidebarItem:** `Text`, `Value`, `Icon`, `Badge`, `IsEnabled` ·
+**Events:** `Clicked`
+
+The header and footer are your own views: hide what doesn't fit the rail in
+`IsCollapsedChanged`.
+
+```xml
+
+
+ ...
+
+
+
+
+
+ ...
+
+```
+
+---
+
### Tabs
A muted pill-shaped list; the active tab is raised on the background color. Panels fade in on switch.
diff --git a/docs/COMPONENTS_ROADMAP.md b/docs/COMPONENTS_ROADMAP.md
index 44070df..b6a4bfe 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-07** (first prerelease, `0.1.0-alpha.1`).
-**Status at a glance:** P0–P5 ✅ done · P6 partly done (resizable, navbar, sidebar open) · P7 partly done · shipped as `0.1.0-alpha.1` (58 families, 89 CLI targets) · **Avalonia (Phase 2): every component ported, not released yet**.
+**Status at a glance:** P0–P6 ✅ done · P7 partly done · shipped as `0.1.0-alpha.1` (58 families, 89 CLI targets) · **Avalonia (Phase 2): every component ported, not released yet**.
---
@@ -187,15 +187,15 @@ Icons: `python scripts/generate-icons.py` regenerates `Icon.cs` from the ShellIc
---
-### P6 — Lower (Advanced) — partly done
+### P6 — Lower (Advanced) ✅
| Component | Priority | Dependencies | ShellUI Ref | Notes |
|-----------|----------|--------------|-------------|-------|
| **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 |
+| **resizable** ✅ | P6.4 | shell | Resizable | Done 2026-10-11 — one template (Resizable + ResizablePanel); drag the divider, nests for both directions |
+| **navbar** ✅ | P6.5 | shell, icon, element-extensions | Navbar | Done 2026-10-11 — one template (Navbar + NavbarItem); links collapse into a menu under the bar |
+| **sidebar** ✅ | P6.6 | shell, icon, element-extensions | Sidebar | Done 2026-10-11 — one template (Sidebar, SidebarGroup, SidebarItem); collapses to an icon rail |
---
@@ -204,9 +204,9 @@ Icons: `python scripts/generate-icons.py` regenerates `Icon.cs` from the ShellIc
|-----------|----------|-------|
| chart (line, bar, pie, area) | P7 | Consider external chart lib integration |
| combobox ✅ | P7 | Done 2026-10-04 — Select with a filter field, Enter picks the first match |
-| file-upload | P7 | File picker |
+| **file-upload** ✅ | P7 | Done 2026-10-11, MAUI and Avalonia — drop zone, platform file picker, list of picked files |
| input-otp ✅ | P7 | Done 2026-10-04 — slots over one hidden field (paste / autofill / numeric keyboard) |
-| date-range-picker | P7 | Date range selection |
+| **date-range-picker** ✅ | P7 | Done 2026-10-11, MAUI and Avalonia — one calendar, start then end; `Calendar` gained `RangeStart` / `RangeEnd` |
| toggle-group ✅ | — | Done 2026-10-05 — single or multiple selection, Default / Outline |
| number-input ✅ | — | Done 2026-10-05 — − / + buttons, min / max clamp, decimal value |
| tag-input ✅ | — | Done 2026-10-05 — chips with remove, Enter / comma / semicolon to commit |
@@ -293,6 +293,5 @@ P7 items marked ✅ above — 58 families, 89 CLI targets, MAUI only.
component APIs, tokens and sizing contract. MAUI is far ahead, so new MAUI components wait
until Avalonia has P0 + P1.
-**MAUI backlog after that:** resizable, navbar, sidebar (P6); chart, file-upload,
-date-range-picker (P7); and the ShellUI Blazor targets not yet ported (button-group,
+**Backlog after that:** chart (P7); and the ShellUI Blazor targets not yet ported (button-group,
input-group, command / command-palette, menubar, navigation-menu, data-table, …).
diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md
index 5503aea..5324a98 100644
--- a/docs/DEVELOPMENT_PLAN.md
+++ b/docs/DEVELOPMENT_PLAN.md
@@ -35,8 +35,8 @@ main ← Phase 1a (2026-07-05), 1b (PR #2), 1c + 1d (PR #3), 1e (PR #4), brand
| Branch | Base | Status | Purpose |
|--------|------|--------|---------|
-| `main` | — | Phase 1a–1e, release `0.1.0-alpha.1`, Phase 2 foundation and P0–P6 merged | 92 registry entries (89 CLI targets, 58 families); Avalonia templates for the foundation and P0–P6 |
-| `feat/avalonia-p7-remaining` | `main` | **Active** | Phase 2: the 14 components that were still MAUI-only |
+| `main` | — | Phase 1a–1e, release `0.1.0-alpha.1`, Phase 2 merged: every component has an Avalonia version | 92 registry entries (89 CLI targets, 58 families), each with MAUI and Avalonia content |
+| `feat/p6-layout-navigation` | `main` | **Active** | resizable, navbar, sidebar (finishes P6), date-range-picker and file-upload (P7), each for MAUI and Avalonia |
---
@@ -496,6 +496,10 @@ Cross-desktop (Windows + macOS + Linux) from one XAML codebase.
wrap-layout is a `Panel` with the MAUI layout's `LastChildFill`. Each installs alone into a
fresh Avalonia app and builds with 0 warnings; checked in the demo in light and dark with
pointer and key input
+- [x] From here on a component lands on both platforms in one branch. First
+ (`feat/p6-layout-navigation`): resizable, navbar and sidebar, which finish P6, then
+ date-range-picker (Calendar gains `RangeStart` / `RangeEnd`) and file-upload from P7; 97
+ registry entries. A release branch follows
- [ ] 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/Calendar.cs b/examples/Avalonia.Demo/Components/UI/Calendar.cs
index 0004b52..182465c 100644
--- a/examples/Avalonia.Demo/Components/UI/Calendar.cs
+++ b/examples/Avalonia.Demo/Components/UI/Calendar.cs
@@ -13,7 +13,8 @@ namespace AvaloniaDemo.Components.UI;
// Month calendar — prev/next header, weekday row, 6x7 day grid of 32px cells. Selected day is
// bg-primary, today is bg-accent, days outside the month are muted. Month names, weekday names
-// and the first day of the week follow the current culture.
+// and the first day of the week follow the current culture. RangeStart and RangeEnd tint a span
+// of days (DateRangePicker uses them).
// Usage:
public class Calendar : Border
{
@@ -26,6 +27,13 @@ public class Calendar : Border
public static readonly StyledProperty MaximumDateProperty =
AvaloniaProperty.Register(nameof(MaximumDate));
+ // The ends of a highlighted span: both ends are bg-primary, the days between bg-accent.
+ public static readonly StyledProperty RangeStartProperty =
+ AvaloniaProperty.Register(nameof(RangeStart));
+
+ public static readonly StyledProperty RangeEndProperty =
+ AvaloniaProperty.Register(nameof(RangeEnd));
+
private const double Cell = 32;
private DateTime _month = new(DateTime.Today.Year, DateTime.Today.Month, 1);
@@ -39,6 +47,8 @@ static Calendar()
SelectedDateProperty.Changed.AddClassHandler((c, _) => c.OnSelectedDateChanged());
MinimumDateProperty.Changed.AddClassHandler((c, _) => c.Refresh());
MaximumDateProperty.Changed.AddClassHandler((c, _) => c.Refresh());
+ RangeStartProperty.Changed.AddClassHandler((c, _) => c.Refresh());
+ RangeEndProperty.Changed.AddClassHandler((c, _) => c.Refresh());
}
public DateTime? SelectedDate
@@ -47,6 +57,18 @@ public DateTime? SelectedDate
set => SetValue(SelectedDateProperty, value);
}
+ public DateTime? RangeStart
+ {
+ get => GetValue(RangeStartProperty);
+ set => SetValue(RangeStartProperty, value);
+ }
+
+ public DateTime? RangeEnd
+ {
+ get => GetValue(RangeEndProperty);
+ set => SetValue(RangeEndProperty, value);
+ }
+
public DateTime? MinimumDate
{
get => GetValue(MinimumDateProperty);
@@ -194,12 +216,13 @@ private void StyleCell(int index)
var date = _dates[index];
var inMonth = date.Month == _month.Month;
var selectable = IsSelectable(date);
- var selected = SelectedDate?.Date == date;
+ var selected = SelectedDate?.Date == date || RangeStart?.Date == date || RangeEnd?.Date == date;
+ var inRange = !selected && RangeStart is { } from && RangeEnd is { } to && date > from.Date && date < to.Date;
var today = date == DateTime.Today;
if (selected)
box.Token(BackgroundProperty, ShellToken.Primary);
- else if (today || (index == _hovered && selectable))
+ else if (inRange || today || (index == _hovered && selectable))
box.Token(BackgroundProperty, ShellToken.Accent);
else
{
@@ -207,8 +230,11 @@ private void StyleCell(int index)
box.Background = Brushes.Transparent;
}
+ // Days inside a span lose their corners, so the span reads as one band.
+ box.CornerRadius = new CornerRadius(inRange ? 0 : ShellTheme.RadiusMd);
+
text.Token(TextBlock.ForegroundProperty,
- selected ? ShellToken.PrimaryForeground : inMonth ? ShellToken.Foreground : ShellToken.MutedForeground);
- box.Opacity = !selectable ? 0.3 : inMonth || selected ? 1 : 0.5;
+ selected ? ShellToken.PrimaryForeground : inMonth || inRange ? ShellToken.Foreground : ShellToken.MutedForeground);
+ box.Opacity = !selectable ? 0.3 : inMonth || selected || inRange ? 1 : 0.5;
}
}
diff --git a/examples/Avalonia.Demo/Components/UI/DateRangePicker.cs b/examples/Avalonia.Demo/Components/UI/DateRangePicker.cs
new file mode 100644
index 0000000..02c944a
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/DateRangePicker.cs
@@ -0,0 +1,242 @@
+using System;
+using System.Globalization;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Date range picker — a DatePicker-style trigger (h-10 rounded-md border) that opens a Calendar.
+// The first pick is the start, the second the end (picking an earlier day starts over); the
+// panel closes when the range is complete. The × clears it.
+// Usage:
+public class DateRangePicker : Border
+{
+ public static readonly StyledProperty StartDateProperty =
+ AvaloniaProperty.Register(nameof(StartDate), defaultBindingMode: BindingMode.TwoWay);
+
+ public static readonly StyledProperty EndDateProperty =
+ AvaloniaProperty.Register(nameof(EndDate), defaultBindingMode: BindingMode.TwoWay);
+
+ public static readonly StyledProperty PlaceholderProperty =
+ AvaloniaProperty.Register(nameof(Placeholder), "Pick a date range");
+
+ // .NET date format string for the trigger text.
+ public static readonly StyledProperty FormatProperty =
+ AvaloniaProperty.Register(nameof(Format), "MMM d, yyyy");
+
+ // Shows the × that clears the range.
+ public static readonly StyledProperty AllowClearProperty =
+ AvaloniaProperty.Register(nameof(AllowClear), true);
+
+ public static readonly StyledProperty MinimumDateProperty =
+ AvaloniaProperty.Register(nameof(MinimumDate));
+
+ public static readonly StyledProperty MaximumDateProperty =
+ AvaloniaProperty.Register(nameof(MaximumDate));
+
+ private readonly Border _trigger;
+ private readonly TextBlock _text;
+ private readonly Border _clear;
+ private readonly Calendar _calendar;
+ private readonly ShellAnchoredPopup _popup;
+
+ static DateRangePicker()
+ {
+ StartDateProperty.Changed.AddClassHandler((p, _) => p.OnRangeChanged());
+ EndDateProperty.Changed.AddClassHandler((p, _) => p.OnRangeChanged());
+ PlaceholderProperty.Changed.AddClassHandler((p, _) => p.UpdateTrigger());
+ FormatProperty.Changed.AddClassHandler((p, _) => p.UpdateTrigger());
+ AllowClearProperty.Changed.AddClassHandler((p, _) => p.UpdateTrigger());
+ MinimumDateProperty.Changed.AddClassHandler((p, e) => p._calendar.MinimumDate = (DateTime?)e.NewValue);
+ MaximumDateProperty.Changed.AddClassHandler((p, e) => p._calendar.MaximumDate = (DateTime?)e.NewValue);
+ IsEnabledProperty.Changed.AddClassHandler((p, _) => p.Opacity = p.IsEnabled ? 1.0 : 0.5);
+ }
+
+ public DateTime? StartDate
+ {
+ get => GetValue(StartDateProperty);
+ set => SetValue(StartDateProperty, value);
+ }
+
+ public DateTime? EndDate
+ {
+ get => GetValue(EndDateProperty);
+ set => SetValue(EndDateProperty, value);
+ }
+
+ public string? Placeholder
+ {
+ get => GetValue(PlaceholderProperty);
+ set => SetValue(PlaceholderProperty, value);
+ }
+
+ public string Format
+ {
+ get => GetValue(FormatProperty);
+ set => SetValue(FormatProperty, value);
+ }
+
+ public bool AllowClear
+ {
+ get => GetValue(AllowClearProperty);
+ set => SetValue(AllowClearProperty, value);
+ }
+
+ public DateTime? MinimumDate
+ {
+ get => GetValue(MinimumDateProperty);
+ set => SetValue(MinimumDateProperty, value);
+ }
+
+ public DateTime? MaximumDate
+ {
+ get => GetValue(MaximumDateProperty);
+ set => SetValue(MaximumDateProperty, value);
+ }
+
+ public bool IsOpen => _popup.IsOpen;
+
+ // Raised when StartDate or EndDate changes.
+ public event EventHandler? RangeChanged;
+
+ public DateRangePicker()
+ {
+ _text = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis };
+ Grid.SetColumn(_text, 1);
+
+ var close = new Icon { Kind = IconName.X, Size = 14, Token = ShellToken.MutedForeground, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ // 14px icon in a 24px click target. Its clicks stop here, so they don't toggle the panel.
+ _clear = new Border { Width = 24, Height = 24, Background = Brushes.Transparent, Child = close, VerticalAlignment = VerticalAlignment.Center, IsVisible = false };
+ AutomationProperties.SetName(_clear, "Clear dates");
+ _clear.PointerEntered += (_, _) => close.Token = ShellToken.Foreground;
+ _clear.PointerExited += (_, _) => close.Token = ShellToken.MutedForeground;
+ _clear.PointerPressed += (_, e) => e.Handled = true;
+ _clear.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ if (IsEffectivelyEnabled) Clear();
+ e.Handled = true;
+ };
+ Grid.SetColumn(_clear, 2);
+
+ _trigger = new Border
+ {
+ Child = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
+ ColumnSpacing = 8,
+ Children = { new Icon { Kind = IconName.Calendar, Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Center }, _text, _clear }
+ },
+ Height = 40,
+ MinWidth = 260,
+ Padding = new Thickness(12, 0, 8, 0),
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ Background = Brushes.Transparent,
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+ _trigger.Token(BorderBrushProperty, ShellToken.Input);
+ _trigger.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return;
+ SetOpen(!IsOpen);
+ e.Handled = true;
+ };
+
+ _calendar = new Calendar();
+ _calendar.DateSelected += (_, date) => Pick(date);
+ var panel = new Border
+ {
+ Child = _calendar,
+ Padding = new Thickness(12),
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ BoxShadow = ShellPopups.PanelShadow()
+ };
+ panel.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border);
+
+ _popup = new ShellAnchoredPopup(_trigger);
+ _popup.Popup.Child = panel;
+ _popup.Closed += (_, _) => _trigger.Token(BorderBrushProperty, ShellToken.Input);
+
+ Child = new Panel { Children = { _trigger, _popup.Popup } };
+ HorizontalAlignment = HorizontalAlignment.Left;
+ ShellFocus.Ring(this, _trigger);
+ UpdateTrigger();
+ }
+
+ public void SetOpen(bool open)
+ {
+ if (open == _popup.IsOpen) return;
+ if (!open)
+ {
+ _popup.Close();
+ return;
+ }
+ UpdateCalendar();
+ _calendar.DisplayMonth = StartDate ?? DateTime.Today;
+ _trigger.Token(BorderBrushProperty, ShellToken.Ring);
+ _popup.Open();
+ }
+
+ public void Close() => SetOpen(false);
+
+ public void Clear()
+ {
+ StartDate = null;
+ EndDate = null;
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if (IsOpen || !ReferenceEquals(e.Source, this) || e.Key is not (Key.Enter or Key.Space or Key.Down)) return;
+ SetOpen(true);
+ e.Handled = true;
+ }
+
+ // The first pick (or a pick with a complete range, or one before the start) begins a new
+ // range; the next pick ends it.
+ private void Pick(DateTime date)
+ {
+ if (StartDate is not { } start || EndDate.HasValue || date < start.Date)
+ {
+ EndDate = null;
+ StartDate = date;
+ return;
+ }
+ EndDate = date;
+ SetOpen(false);
+ }
+
+ private void OnRangeChanged()
+ {
+ UpdateTrigger();
+ UpdateCalendar();
+ RangeChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void UpdateCalendar()
+ {
+ // The calendar marks the range itself; its own single selection stays empty.
+ _calendar.SelectedDate = null;
+ _calendar.RangeStart = StartDate;
+ _calendar.RangeEnd = EndDate;
+ }
+
+ private void UpdateTrigger()
+ {
+ var culture = CultureInfo.CurrentCulture;
+ var hasValue = StartDate.HasValue || EndDate.HasValue;
+ _text.Text = StartDate is { } start
+ ? EndDate is { } end ? $"{start.ToString(Format, culture)} – {end.ToString(Format, culture)}" : $"{start.ToString(Format, culture)} – …"
+ : Placeholder ?? string.Empty;
+ _text.Token(TextBlock.ForegroundProperty, hasValue ? ShellToken.Foreground : ShellToken.MutedForeground);
+ _clear.IsVisible = AllowClear && hasValue;
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/FileUpload.cs b/examples/Avalonia.Demo/Components/UI/FileUpload.cs
new file mode 100644
index 0000000..6551afe
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/FileUpload.cs
@@ -0,0 +1,301 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Controls.Shapes;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Platform.Storage;
+
+namespace AvaloniaDemo.Components.UI;
+
+// File picker — a dashed drop zone (rounded-lg border-2 border-dashed p-8) with an upload icon
+// and a prompt; clicking it (or Enter / Space) opens the file picker, and files can be dropped on
+// it. The picked files are listed underneath with a × to remove each.
+//
+// Read a file with IStorageFile.OpenReadAsync().
+public class FileUpload : Border, IShellFocusable
+{
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text), "Drag and drop or click here to upload files");
+
+ // More than one file can be picked; new picks are added to the list.
+ public static readonly StyledProperty MultipleProperty =
+ AvaloniaProperty.Register(nameof(Multiple));
+
+ // The most files the list holds when Multiple is on. 0 means no limit.
+ public static readonly StyledProperty MaxFilesProperty =
+ AvaloniaProperty.Register(nameof(MaxFiles));
+
+ // Which files the picker offers, e.g. FilePickerFileTypes.ImageAll. Null offers every file.
+ public static readonly StyledProperty?> FileTypesProperty =
+ AvaloniaProperty.Register?>(nameof(FileTypes));
+
+ // The list of picked files under the drop zone.
+ public static readonly StyledProperty ShowFilesProperty =
+ AvaloniaProperty.Register(nameof(ShowFiles), true);
+
+ private readonly List _files = new();
+ private readonly Panel _zone;
+ private readonly Rectangle _outline;
+ private readonly TextBlock _text;
+ private readonly TextBlock _hint;
+ private readonly StackPanel _list = new() { Spacing = 4, Margin = new Thickness(0, 8, 0, 0), IsVisible = false };
+ private bool _dragOver;
+ private bool _picking;
+
+ static FileUpload()
+ {
+ TextProperty.Changed.AddClassHandler((f, _) => f.Update());
+ MultipleProperty.Changed.AddClassHandler((f, _) => f.Update());
+ MaxFilesProperty.Changed.AddClassHandler((f, _) => f.Update());
+ ShowFilesProperty.Changed.AddClassHandler((f, _) => f.Rebuild());
+ IsEnabledProperty.Changed.AddClassHandler((f, _) => f.Paint());
+ }
+
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public bool Multiple
+ {
+ get => GetValue(MultipleProperty);
+ set => SetValue(MultipleProperty, value);
+ }
+
+ public int MaxFiles
+ {
+ get => GetValue(MaxFilesProperty);
+ set => SetValue(MaxFilesProperty, value);
+ }
+
+ public IReadOnlyList? FileTypes
+ {
+ get => GetValue(FileTypesProperty);
+ set => SetValue(FileTypesProperty, value);
+ }
+
+ public bool ShowFiles
+ {
+ get => GetValue(ShowFilesProperty);
+ set => SetValue(ShowFilesProperty, value);
+ }
+
+ public IReadOnlyList Files => _files;
+
+ public event EventHandler>? FilesChanged;
+
+ public FileUpload()
+ {
+ _text = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, TextAlignment = TextAlignment.Center, TextWrapping = TextWrapping.Wrap };
+ _text.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+ _hint = new TextBlock { FontSize = 12, TextAlignment = TextAlignment.Center, IsVisible = false };
+ _hint.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+
+ // A Border has no dashed stroke, so the outline is a Rectangle behind the content.
+ _outline = new Rectangle { StrokeThickness = 2, StrokeDashArray = new Avalonia.Collections.AvaloniaList { 3, 3 }, RadiusX = ShellTheme.RadiusLg, RadiusY = ShellTheme.RadiusLg };
+ _zone = new Panel
+ {
+ // Transparent, not null, so the whole zone takes clicks and drops.
+ Background = Brushes.Transparent,
+ Cursor = new Cursor(StandardCursorType.Hand),
+ Children =
+ {
+ _outline,
+ new StackPanel
+ {
+ Spacing = 4,
+ Margin = new Thickness(32),
+ Children =
+ {
+ new Icon { Kind = IconName.Upload, Size = 32, Token = ShellToken.MutedForeground, HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, 0, 0, 8) },
+ _text,
+ _hint
+ }
+ }
+ }
+ };
+ _zone.PointerEntered += (_, _) => Paint();
+ _zone.PointerExited += (_, _) => Paint();
+ _zone.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ _ = PickAsync();
+ e.Handled = true;
+ };
+
+ DragDrop.SetAllowDrop(_zone, true);
+ _zone.AddHandler(DragDrop.DragEnterEvent, (_, e) => SetDragOver(e, true));
+ _zone.AddHandler(DragDrop.DragOverEvent, (_, e) => SetDragOver(e, true));
+ _zone.AddHandler(DragDrop.DragLeaveEvent, (_, e) => SetDragOver(e, false));
+ _zone.AddHandler(DragDrop.DropEvent, OnDrop);
+
+ // The focus ring is a shadow, and a border with no background paints none.
+ var ring = new Border { Child = _zone, CornerRadius = new CornerRadius(ShellTheme.RadiusLg), Background = Brushes.Transparent };
+ Child = new StackPanel { Children = { ring, _list } };
+ ShellFocus.Ring(this, ring);
+ Update();
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if (!ReferenceEquals(e.Source, this) || e.Key is not (Key.Enter or Key.Space)) return;
+ _ = PickAsync();
+ e.Handled = true;
+ }
+
+ // Opens the file picker and adds what was picked.
+ public async Task PickAsync()
+ {
+ if (!IsEffectivelyEnabled || _picking || TopLevel.GetTopLevel(this) is not { } top) return;
+ _picking = true;
+ try
+ {
+ var picked = await top.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ AllowMultiple = Multiple,
+ FileTypeFilter = FileTypes
+ });
+ Add(picked);
+ }
+ catch (Exception)
+ {
+ // The picker isn't available here.
+ }
+ finally
+ {
+ _picking = false;
+ }
+ }
+
+ // Adds files as a pick or a drop would: one file replaces the current one unless Multiple
+ // is on, and the list never grows past MaxFiles.
+ public void Add(IEnumerable files)
+ {
+ var changed = false;
+ foreach (var file in files)
+ {
+ if (!Multiple)
+ {
+ _files.Clear();
+ _files.Add(file);
+ changed = true;
+ break;
+ }
+ if (MaxFiles > 0 && _files.Count >= MaxFiles) break;
+ if (_files.Any(f => f.Path == file.Path)) continue;
+ _files.Add(file);
+ changed = true;
+ }
+ if (changed) OnFilesChanged();
+ }
+
+ public void Remove(IStorageFile file)
+ {
+ if (_files.Remove(file)) OnFilesChanged();
+ }
+
+ public void Clear()
+ {
+ if (_files.Count == 0) return;
+ _files.Clear();
+ OnFilesChanged();
+ }
+
+ private void OnFilesChanged()
+ {
+ Rebuild();
+ FilesChanged?.Invoke(this, _files.ToList());
+ }
+
+ private void SetDragOver(DragEventArgs e, bool over)
+ {
+ e.DragEffects = over && IsEffectivelyEnabled ? DragDropEffects.Copy : DragDropEffects.None;
+ _dragOver = over && IsEffectivelyEnabled;
+ Paint();
+ }
+
+ private void OnDrop(object? sender, DragEventArgs e)
+ {
+ _dragOver = false;
+ Paint();
+ if (!IsEffectivelyEnabled) return;
+ if (e.DataTransfer.TryGetFiles() is { } items) Add(items.OfType());
+ e.Handled = true;
+ }
+
+ private void Rebuild()
+ {
+ _list.Children.Clear();
+ _list.IsVisible = ShowFiles && _files.Count > 0;
+ if (!ShowFiles) return;
+ foreach (var file in _files)
+ _list.Children.Add(CreateRow(file));
+ }
+
+ // rounded-md border px-3 h-10: file icon, name, × to remove.
+ private Control CreateRow(IStorageFile file)
+ {
+ var name = new TextBlock { Text = file.Name, FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis };
+ name.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+ Grid.SetColumn(name, 1);
+ var close = new Icon { Kind = IconName.X, Size = 14, Token = ShellToken.MutedForeground, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ // 14px icon in a 24px click target.
+ var remove = new Border { Width = 24, Height = 24, Background = Brushes.Transparent, Child = close, VerticalAlignment = VerticalAlignment.Center, Cursor = new Cursor(StandardCursorType.Hand) };
+ AutomationProperties.SetName(remove, $"Remove {file.Name}");
+ remove.PointerEntered += (_, _) => close.Token = ShellToken.Destructive;
+ remove.PointerExited += (_, _) => close.Token = ShellToken.MutedForeground;
+ remove.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ if (IsEffectivelyEnabled) Remove(file);
+ e.Handled = true;
+ };
+ Grid.SetColumn(remove, 2);
+
+ var row = new Border
+ {
+ Child = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
+ ColumnSpacing = 8,
+ Children = { new Icon { Kind = IconName.File, Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Center }, name, remove }
+ },
+ Height = 40,
+ Padding = new Thickness(12, 0, 8, 0),
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd)
+ };
+ row.Token(BorderBrushProperty, ShellToken.Border);
+ return row;
+ }
+
+ private void Update()
+ {
+ _text.Text = Text ?? string.Empty;
+ _hint.Text = MaxFiles > 1 ? $"Select up to {MaxFiles} files" : string.Empty;
+ _hint.IsVisible = Multiple && MaxFiles > 1;
+ AutomationProperties.SetName(this, Text ?? string.Empty);
+ Paint();
+ }
+
+ private void Paint()
+ {
+ var active = _dragOver || (_zone.IsPointerOver && IsEffectivelyEnabled);
+ _outline.Token(Shape.StrokeProperty, _dragOver ? ShellToken.Primary : active ? ShellToken.Ring : ShellToken.Border);
+ if (active) _outline.Token(Shape.FillProperty, ShellToken.Muted);
+ else
+ {
+ _outline.ClearToken(Shape.FillProperty);
+ _outline.Fill = null;
+ }
+ Opacity = IsEnabled ? 1.0 : 0.5;
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/Navbar.cs b/examples/Avalonia.Demo/Components/UI/Navbar.cs
new file mode 100644
index 0000000..9130fac
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/Navbar.cs
@@ -0,0 +1,364 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Top navigation bar — h-14 border-b bg-background: a brand (icon and title), links, and actions
+// at the end. Narrower than CollapseBelow, the links move into a menu that a button opens under
+// the bar.
+//
+//
+//
+//
+//
+//
+//
+public class Navbar : Border
+{
+ public static readonly StyledProperty TitleProperty =
+ AvaloniaProperty.Register(nameof(Title));
+
+ public static readonly StyledProperty IconProperty =
+ AvaloniaProperty.Register(nameof(Icon));
+
+ // Replaces the icon and title.
+ public static readonly StyledProperty BrandProperty =
+ AvaloniaProperty.Register(nameof(Brand));
+
+ // Shown at the end of the bar (buttons, an avatar, a theme toggle).
+ public static readonly StyledProperty ActionsProperty =
+ AvaloniaProperty.Register(nameof(Actions));
+
+ // The active item's Value.
+ public static readonly StyledProperty SelectedValueProperty =
+ AvaloniaProperty.Register(nameof(SelectedValue), defaultBindingMode: BindingMode.TwoWay);
+
+ // Below this width the links collapse into the menu. 0 never collapses.
+ public static readonly StyledProperty CollapseBelowProperty =
+ AvaloniaProperty.Register(nameof(CollapseBelow), 640);
+
+ public static readonly StyledProperty IsMenuOpenProperty =
+ AvaloniaProperty.Register(nameof(IsMenuOpen), defaultBindingMode: BindingMode.TwoWay);
+
+ private readonly Icon _brandIcon;
+ private readonly TextBlock _title;
+ private readonly StackPanel _brandRow;
+ private readonly Border _brand;
+ private readonly StackPanel _links;
+ private readonly Border _actions;
+ private readonly Border _menuButton;
+ private readonly Icon _menuIcon;
+ private readonly StackPanel _menu;
+ private bool _collapsed;
+
+ static Navbar()
+ {
+ TitleProperty.Changed.AddClassHandler((n, _) => n.UpdateBrand());
+ IconProperty.Changed.AddClassHandler((n, _) => n.UpdateBrand());
+ BrandProperty.Changed.AddClassHandler((n, _) => n.UpdateBrand());
+ ActionsProperty.Changed.AddClassHandler((n, e) => n._actions.Child = e.NewValue as Control);
+ CollapseBelowProperty.Changed.AddClassHandler((n, _) => n.UpdateLayout(force: false));
+ IsMenuOpenProperty.Changed.AddClassHandler((n, _) => n.UpdateLayout(force: false));
+ SelectedValueProperty.Changed.AddClassHandler((n, _) =>
+ {
+ n.Refresh();
+ n.SelectedValueChanged?.Invoke(n, n.SelectedValue);
+ });
+ }
+
+ public string? Title
+ {
+ get => GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public Control? Brand
+ {
+ get => GetValue(BrandProperty);
+ set => SetValue(BrandProperty, value);
+ }
+
+ public Control? Actions
+ {
+ get => GetValue(ActionsProperty);
+ set => SetValue(ActionsProperty, value);
+ }
+
+ public string? SelectedValue
+ {
+ get => GetValue(SelectedValueProperty);
+ set => SetValue(SelectedValueProperty, value);
+ }
+
+ public double CollapseBelow
+ {
+ get => GetValue(CollapseBelowProperty);
+ set => SetValue(CollapseBelowProperty, value);
+ }
+
+ public bool IsMenuOpen
+ {
+ get => GetValue(IsMenuOpenProperty);
+ set => SetValue(IsMenuOpenProperty, value);
+ }
+
+ // The NavbarItem children.
+ [Content]
+ public Controls Items { get; } = new();
+
+ public event EventHandler? SelectedValueChanged;
+
+ public Navbar()
+ {
+ _brandIcon = new Icon { Size = 20, Token = ShellToken.Foreground, VerticalAlignment = VerticalAlignment.Center, IsVisible = false };
+ _title = new TextBlock { FontSize = 16, FontWeight = FontWeight.SemiBold, VerticalAlignment = VerticalAlignment.Center };
+ _title.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+ _brandRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Children = { _brandIcon, _title } };
+ _brand = new Border { VerticalAlignment = VerticalAlignment.Center, Child = _brandRow };
+ _links = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(16, 0, 0, 0) };
+ Grid.SetColumn(_links, 1);
+ _actions = new Border { VerticalAlignment = VerticalAlignment.Center };
+ Grid.SetColumn(_actions, 2);
+
+ _menuIcon = new Icon { Kind = IconName.Menu, Size = 18, Token = ShellToken.Foreground, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ _menuButton = new Border
+ {
+ Child = _menuIcon,
+ Width = 36,
+ Height = 36,
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ Background = Brushes.Transparent,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(4, 0, 0, 0),
+ Cursor = new Cursor(StandardCursorType.Hand),
+ IsVisible = false
+ };
+ Grid.SetColumn(_menuButton, 3);
+ ShellFocus.Ring(_menuButton, _menuButton);
+ _menuButton.PointerEntered += (_, _) => _menuButton.Token(BackgroundProperty, ShellToken.Accent);
+ _menuButton.PointerExited += (_, _) =>
+ {
+ _menuButton.ClearToken(BackgroundProperty);
+ _menuButton.Background = Brushes.Transparent;
+ };
+ _menuButton.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ IsMenuOpen = !IsMenuOpen;
+ e.Handled = true;
+ };
+ _menuButton.KeyDown += (_, e) =>
+ {
+ if (e.Key is not (Key.Enter or Key.Space)) return;
+ IsMenuOpen = !IsMenuOpen;
+ e.Handled = true;
+ };
+
+ var bar = new Grid
+ {
+ Height = 56,
+ Margin = new Thickness(16, 0),
+ ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto,Auto"),
+ Children = { _brand, _links, _actions, _menuButton }
+ };
+ _menu = new StackPanel { Spacing = 2, Margin = new Thickness(12, 0, 12, 12), IsVisible = false };
+ var edge = new Border { Height = 1 };
+ edge.Token(BackgroundProperty, ShellToken.Border);
+
+ Child = new StackPanel { Children = { bar, _menu, edge } };
+ this.Token(BackgroundProperty, ShellToken.Background);
+ Items.CollectionChanged += (_, _) => UpdateLayout(force: true);
+ SizeChanged += (_, _) => UpdateLayout(force: false);
+ UpdateBrand();
+ }
+
+ private List ItemList => Items.OfType().ToList();
+
+ internal void Select(NavbarItem item)
+ {
+ if (!string.IsNullOrEmpty(item.Value)) SelectedValue = item.Value;
+ IsMenuOpen = false;
+ }
+
+ private void UpdateBrand()
+ {
+ _brandIcon.Kind = Icon;
+ _brandIcon.IsVisible = Icon != IconName.None;
+ _title.Text = Title ?? string.Empty;
+ _title.IsVisible = !string.IsNullOrEmpty(Title);
+ _brand.Child = Brand ?? _brandRow;
+ }
+
+ // The links live in the bar or, when collapsed, in the menu under it.
+ private void UpdateLayout(bool force)
+ {
+ var items = ItemList;
+ var width = Bounds.Width;
+ var collapsed = CollapseBelow > 0 && width > 0 && width < CollapseBelow && items.Count > 0;
+ if (collapsed != _collapsed || force)
+ {
+ _collapsed = collapsed;
+ _links.Children.Clear();
+ _menu.Children.Clear();
+ foreach (var item in items)
+ {
+ if (collapsed) _menu.Children.Add(item);
+ else _links.Children.Add(item);
+ }
+ }
+ _menuButton.IsVisible = _collapsed;
+ _menu.IsVisible = _collapsed && IsMenuOpen;
+ _menuIcon.Kind = IsMenuOpen && _collapsed ? IconName.X : IconName.Menu;
+ AutomationProperties.SetName(_menuButton, IsMenuOpen ? "Close menu" : "Open menu");
+ Refresh();
+ }
+
+ // Items don't track the bar's state: the bar pushes it to them.
+ private void Refresh()
+ {
+ foreach (var item in ItemList)
+ item.Apply(isActive: !string.IsNullOrEmpty(item.Value) && item.Value == SelectedValue, inMenu: _collapsed);
+ }
+}
+
+// A link in a Navbar — text-sm font-medium, muted until hovered or active. A tab stop that
+// activates with Enter or Space.
+public class NavbarItem : Border, IShellFocusable
+{
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text));
+
+ // Identifies the item for Navbar.SelectedValue.
+ public static readonly StyledProperty ValueProperty =
+ AvaloniaProperty.Register(nameof(Value));
+
+ public static readonly StyledProperty IconProperty =
+ AvaloniaProperty.Register(nameof(Icon));
+
+ private readonly Icon _icon;
+ private readonly TextBlock _label;
+ private bool _active;
+
+ static NavbarItem()
+ {
+ TextProperty.Changed.AddClassHandler((i, _) => i.Update());
+ IconProperty.Changed.AddClassHandler((i, _) => i.Update());
+ IsEnabledProperty.Changed.AddClassHandler((i, _) => i.Update());
+ }
+
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public string? Value
+ {
+ get => GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public event EventHandler? Clicked;
+
+ public NavbarItem()
+ {
+ _icon = new Icon { Size = 16, IsVisible = false, VerticalAlignment = VerticalAlignment.Center };
+ _label = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center };
+ Child = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center, Children = { _icon, _label } };
+ Height = 36;
+ Padding = new Thickness(12, 0);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd);
+ HorizontalAlignment = HorizontalAlignment.Left;
+ Cursor = new Cursor(StandardCursorType.Hand);
+ ShellFocus.Ring(this, this);
+ Update();
+ }
+
+ protected override void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ Update();
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ Update();
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ Press();
+ e.Handled = true;
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if (e.Key is not (Key.Enter or Key.Space)) return;
+ Press();
+ e.Handled = true;
+ }
+
+ // Picks the link in code, as a click or Enter/Space would.
+ public void Press()
+ {
+ if (!IsEffectivelyEnabled) return;
+ this.FindParentOfType()?.Select(this);
+ Clicked?.Invoke(this, EventArgs.Empty);
+ }
+
+ internal void Apply(bool isActive, bool inMenu)
+ {
+ _active = isActive;
+ // In the menu each link is a full-width row.
+ HorizontalAlignment = inMenu ? HorizontalAlignment.Stretch : HorizontalAlignment.Left;
+ Update();
+ }
+
+ private void Update()
+ {
+ _label.Text = Text ?? string.Empty;
+ _label.FontWeight = _active ? FontWeight.Medium : FontWeight.Normal;
+ _icon.Kind = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+
+ var hovered = IsPointerOver && IsEffectivelyEnabled;
+ var foreground = _active || hovered ? ShellToken.Foreground : ShellToken.MutedForeground;
+ _label.Token(TextBlock.ForegroundProperty, foreground);
+ _icon.Token = foreground;
+
+ if (_active) this.Token(BackgroundProperty, ShellToken.Accent);
+ else if (hovered) this.Token(BackgroundProperty, ShellToken.Muted);
+ else
+ {
+ // Transparent, not null, so the whole link stays hit-testable.
+ this.ClearToken(BackgroundProperty);
+ Background = Brushes.Transparent;
+ }
+ Opacity = IsEnabled ? 1.0 : 0.5;
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/Resizable.cs b/examples/Avalonia.Demo/Components/UI/Resizable.cs
new file mode 100644
index 0000000..1e6443e
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/Resizable.cs
@@ -0,0 +1,282 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Resizable panels — panels side by side (or stacked) with a hairline between them; drag the
+// hairline to move space from one neighbor to the other. Each divider is a tab stop: the arrow
+// keys move it. Give the group a size.
+//
+// ...any control...
+// ...
+//
+public class Resizable : Border
+{
+ public static readonly StyledProperty OrientationProperty =
+ AvaloniaProperty.Register(nameof(Orientation), Orientation.Horizontal);
+
+ // The short bar on each divider. The divider can be dragged with or without it.
+ public static readonly StyledProperty ShowHandleProperty =
+ AvaloniaProperty.Register(nameof(ShowHandle), true);
+
+ // The divider is a hairline; its grab area reaches this far into the panels on each side.
+ private const double GrabReach = 4;
+ private const double KeyStep = 16;
+
+ private readonly Grid _grid = new() { ClipToBounds = true };
+ private double _startBefore;
+ private double _startAfter;
+ private Point? _dragFrom;
+
+ static Resizable()
+ {
+ OrientationProperty.Changed.AddClassHandler((r, _) => r.Rebuild());
+ ShowHandleProperty.Changed.AddClassHandler((r, _) => r.Rebuild());
+ }
+
+ public Orientation Orientation
+ {
+ get => GetValue(OrientationProperty);
+ set => SetValue(OrientationProperty, value);
+ }
+
+ public bool ShowHandle
+ {
+ get => GetValue(ShowHandleProperty);
+ set => SetValue(ShowHandleProperty, value);
+ }
+
+ // The ResizablePanel children.
+ [Content]
+ public Controls Panels { get; } = new();
+
+ // Raised when a drag ends or a key moves a divider.
+ public event EventHandler? Resized;
+
+ public Resizable()
+ {
+ Child = _grid;
+ Panels.CollectionChanged += (_, _) => Rebuild();
+ }
+
+ private bool IsHorizontal => Orientation == Orientation.Horizontal;
+ private List PanelList => Panels.OfType().ToList();
+
+ private void Rebuild()
+ {
+ _grid.Children.Clear();
+ _grid.ColumnDefinitions.Clear();
+ _grid.RowDefinitions.Clear();
+
+ var panels = PanelList;
+ for (var i = 0; i < panels.Count; i++)
+ {
+ var panel = panels[i];
+ panel.SizeSet -= ApplySizes;
+ panel.SizeSet += ApplySizes;
+ if (i > 0) AddTrack(new GridLength(1), CreateHandle(i - 1));
+ AddTrack(new GridLength(Math.Max(0.01, panel.Size), GridUnitType.Star), panel);
+ }
+ }
+
+ private void AddTrack(GridLength length, Control control)
+ {
+ if (IsHorizontal)
+ {
+ _grid.ColumnDefinitions.Add(new ColumnDefinition(length));
+ Grid.SetColumn(control, _grid.ColumnDefinitions.Count - 1);
+ Grid.SetRow(control, 0);
+ }
+ else
+ {
+ _grid.RowDefinitions.Add(new RowDefinition(length));
+ Grid.SetRow(control, _grid.RowDefinitions.Count - 1);
+ Grid.SetColumn(control, 0);
+ }
+ _grid.Children.Add(control);
+ }
+
+ // Panel i sits on track 2i, the divider after it on track 2i + 1.
+ private void ApplySizes()
+ {
+ var panels = PanelList;
+ for (var i = 0; i < panels.Count; i++)
+ {
+ var length = new GridLength(Math.Max(0.01, panels[i].Size), GridUnitType.Star);
+ if (IsHorizontal && 2 * i < _grid.ColumnDefinitions.Count) _grid.ColumnDefinitions[2 * i].Width = length;
+ else if (!IsHorizontal && 2 * i < _grid.RowDefinitions.Count) _grid.RowDefinitions[2 * i].Height = length;
+ }
+ }
+
+ private Control CreateHandle(int index)
+ {
+ var line = new Border();
+ line.Token(BackgroundProperty, ShellToken.Border);
+ if (IsHorizontal)
+ {
+ line.Width = 1;
+ line.HorizontalAlignment = HorizontalAlignment.Center;
+ }
+ else
+ {
+ line.Height = 1;
+ line.VerticalAlignment = VerticalAlignment.Center;
+ }
+
+ // A short rounded bar across the hairline; it also carries the focus ring.
+ var grip = new Border
+ {
+ Width = IsHorizontal ? 5 : 28,
+ Height = IsHorizontal ? 28 : 5,
+ BorderThickness = new Thickness(ShowHandle ? 1 : 0),
+ CornerRadius = new CornerRadius(2.5),
+ // Not null: a border with no background paints no focus ring.
+ Background = Brushes.Transparent,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ if (ShowHandle) grip.Token(BackgroundProperty, ShellToken.Border).Token(BorderBrushProperty, ShellToken.Ring);
+
+ var handle = new Border
+ {
+ Child = new Panel { Children = { line, grip } },
+ // Transparent, not null, so the whole grab area takes the pointer.
+ Background = Brushes.Transparent,
+ Margin = IsHorizontal ? new Thickness(-GrabReach, 0) : new Thickness(0, -GrabReach),
+ ZIndex = 1,
+ Cursor = new Cursor(IsHorizontal ? StandardCursorType.SizeWestEast : StandardCursorType.SizeNorthSouth)
+ };
+ AutomationProperties.SetName(handle, "Resize");
+ ShellFocus.Ring(handle, grip);
+
+ handle.PointerEntered += (_, _) => line.Token(BackgroundProperty, ShellToken.Ring);
+ handle.PointerExited += (_, _) =>
+ {
+ if (_dragFrom is null) line.Token(BackgroundProperty, ShellToken.Border);
+ };
+ handle.PointerPressed += (_, e) =>
+ {
+ if (!e.GetCurrentPoint(handle).Properties.IsLeftButtonPressed || !Start(index)) return;
+ _dragFrom = e.GetPosition(this);
+ e.Pointer.Capture(handle);
+ e.Handled = true;
+ };
+ handle.PointerMoved += (_, e) =>
+ {
+ if (_dragFrom is not { } from) return;
+ var now = e.GetPosition(this);
+ Move(index, IsHorizontal ? now.X - from.X : now.Y - from.Y);
+ e.Handled = true;
+ };
+ handle.PointerReleased += (_, e) =>
+ {
+ if (_dragFrom is null) return;
+ e.Pointer.Capture(null);
+ e.Handled = true;
+ };
+ handle.PointerCaptureLost += (_, _) =>
+ {
+ if (_dragFrom is null) return;
+ _dragFrom = null;
+ if (!handle.IsPointerOver) line.Token(BackgroundProperty, ShellToken.Border);
+ Resized?.Invoke(this, EventArgs.Empty);
+ };
+ handle.KeyDown += (_, e) =>
+ {
+ var back = IsHorizontal ? Key.Left : Key.Up;
+ var forward = IsHorizontal ? Key.Right : Key.Down;
+ if (e.Key != back && e.Key != forward) return;
+ if (Start(index))
+ {
+ Move(index, e.Key == forward ? KeyStep : -KeyStep);
+ Resized?.Invoke(this, EventArgs.Empty);
+ }
+ e.Handled = true;
+ };
+ return handle;
+ }
+
+ // Remembers the two neighbors' sizes at the start of a move.
+ private bool Start(int index)
+ {
+ var panels = PanelList;
+ if (index + 1 >= panels.Count) return false;
+ _startBefore = IsHorizontal ? panels[index].Bounds.Width : panels[index].Bounds.Height;
+ _startAfter = IsHorizontal ? panels[index + 1].Bounds.Width : panels[index + 1].Bounds.Height;
+ return _startBefore + _startAfter > 0;
+ }
+
+ // Space only moves between the two neighbors, and neither goes under its minimum.
+ private void Move(int index, double delta)
+ {
+ var panels = PanelList;
+ if (index + 1 >= panels.Count) return;
+ var before = panels[index];
+ var after = panels[index + 1];
+ var total = _startBefore + _startAfter;
+ var low = Math.Min(before.MinSize, total / 2);
+ var high = total - Math.Min(after.MinSize, total / 2);
+ var size = Math.Clamp(_startBefore + delta, low, high);
+ var weight = before.Size + after.Size;
+ before.SetSize(weight * size / total);
+ after.SetSize(weight - before.Size);
+ ApplySizes();
+ }
+}
+
+// One panel of a Resizable. Size is its share of the space (2 is twice as much as 1).
+public class ResizablePanel : Border
+{
+ public static readonly StyledProperty SizeProperty =
+ AvaloniaProperty.Register(nameof(Size), 1, defaultBindingMode: BindingMode.TwoWay);
+
+ // The smallest the panel can be dragged to, in device-independent pixels.
+ public static readonly StyledProperty MinSizeProperty =
+ AvaloniaProperty.Register(nameof(MinSize), 48);
+
+ private bool _dragging;
+
+ static ResizablePanel()
+ {
+ SizeProperty.Changed.AddClassHandler((p, _) =>
+ {
+ if (!p._dragging) p.SizeSet?.Invoke();
+ });
+ }
+
+ public double Size
+ {
+ get => GetValue(SizeProperty);
+ set => SetValue(SizeProperty, value);
+ }
+
+ public double MinSize
+ {
+ get => GetValue(MinSizeProperty);
+ set => SetValue(MinSizeProperty, value);
+ }
+
+ internal event Action? SizeSet;
+
+ public ResizablePanel()
+ {
+ ClipToBounds = true;
+ }
+
+ // Set by the group during a drag; it applies the new sizes itself.
+ internal void SetSize(double size)
+ {
+ _dragging = true;
+ Size = size;
+ _dragging = false;
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/Sidebar.cs b/examples/Avalonia.Demo/Components/UI/Sidebar.cs
new file mode 100644
index 0000000..e58d6e6
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/Sidebar.cs
@@ -0,0 +1,396 @@
+using System;
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Animation.Easings;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+
+namespace AvaloniaDemo.Components.UI;
+
+// App sidebar — a w-64 column (bg-card, border on its inner edge) with a header, scrolling
+// groups of items and a footer. Collapsed, it is a w-14 rail that shows the icons only. Put it in
+// a column next to your content.
+//
+// ...any control...
+//
+//
+//
+//
+// ...
+//
+// Collapse it with IsCollapsed or Toggle().
+public class Sidebar : Border
+{
+ public static readonly StyledProperty IsCollapsedProperty =
+ AvaloniaProperty.Register(nameof(IsCollapsed), defaultBindingMode: BindingMode.TwoWay);
+
+ // The active item's Value.
+ public static readonly StyledProperty SelectedValueProperty =
+ AvaloniaProperty.Register(nameof(SelectedValue), defaultBindingMode: BindingMode.TwoWay);
+
+ public static readonly StyledProperty ExpandedWidthProperty =
+ AvaloniaProperty.Register(nameof(ExpandedWidth), 256);
+
+ public static readonly StyledProperty CollapsedWidthProperty =
+ AvaloniaProperty.Register(nameof(CollapsedWidth), 56);
+
+ // Which edge of the window it sits on; the border is drawn on the other edge.
+ public static readonly StyledProperty SideProperty =
+ AvaloniaProperty.Register(nameof(Side));
+
+ public static readonly StyledProperty HeaderProperty =
+ AvaloniaProperty.Register(nameof(Header));
+
+ public static readonly StyledProperty FooterProperty =
+ AvaloniaProperty.Register(nameof(Footer));
+
+ private readonly Border _header = new() { Padding = new Thickness(8), IsVisible = false };
+ private readonly Border _footer = new() { Padding = new Thickness(8), IsVisible = false };
+ private readonly StackPanel _groups = new() { Spacing = 8, Margin = new Thickness(8) };
+ private readonly Border _edge = new() { Width = 1 };
+
+ static Sidebar()
+ {
+ IsCollapsedProperty.Changed.AddClassHandler((s, _) =>
+ {
+ s.Refresh();
+ s.Width = s.IsCollapsed ? s.CollapsedWidth : s.ExpandedWidth;
+ s.IsCollapsedChanged?.Invoke(s, s.IsCollapsed);
+ });
+ SelectedValueProperty.Changed.AddClassHandler((s, _) =>
+ {
+ s.Refresh();
+ s.SelectedValueChanged?.Invoke(s, s.SelectedValue);
+ });
+ ExpandedWidthProperty.Changed.AddClassHandler((s, _) => s.SnapWidth());
+ CollapsedWidthProperty.Changed.AddClassHandler((s, _) => s.SnapWidth());
+ SideProperty.Changed.AddClassHandler((s, _) => s.UpdateEdge());
+ HeaderProperty.Changed.AddClassHandler((s, _) => s.UpdateSlots());
+ FooterProperty.Changed.AddClassHandler((s, _) => s.UpdateSlots());
+ }
+
+ public bool IsCollapsed
+ {
+ get => GetValue(IsCollapsedProperty);
+ set => SetValue(IsCollapsedProperty, value);
+ }
+
+ public string? SelectedValue
+ {
+ get => GetValue(SelectedValueProperty);
+ set => SetValue(SelectedValueProperty, value);
+ }
+
+ public double ExpandedWidth
+ {
+ get => GetValue(ExpandedWidthProperty);
+ set => SetValue(ExpandedWidthProperty, value);
+ }
+
+ public double CollapsedWidth
+ {
+ get => GetValue(CollapsedWidthProperty);
+ set => SetValue(CollapsedWidthProperty, value);
+ }
+
+ public SidebarSide Side
+ {
+ get => GetValue(SideProperty);
+ set => SetValue(SideProperty, value);
+ }
+
+ public Control? Header
+ {
+ get => GetValue(HeaderProperty);
+ set => SetValue(HeaderProperty, value);
+ }
+
+ public Control? Footer
+ {
+ get => GetValue(FooterProperty);
+ set => SetValue(FooterProperty, value);
+ }
+
+ // The SidebarGroup children.
+ [Content]
+ public Controls Groups => _groups.Children;
+
+ public event EventHandler? IsCollapsedChanged;
+ public event EventHandler? SelectedValueChanged;
+
+ public Sidebar()
+ {
+ _groups.Children.CollectionChanged += (_, _) => Refresh();
+ _edge.Token(BackgroundProperty, ShellToken.Border);
+ var scroll = new ScrollViewer { Content = _groups, VerticalScrollBarVisibility = ScrollBarVisibility.Hidden, HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled };
+ Grid.SetRow(scroll, 1);
+ Grid.SetRow(_footer, 2);
+
+ Child = new Panel
+ {
+ Children =
+ {
+ new Grid { RowDefinitions = new RowDefinitions("Auto,*,Auto"), Children = { _header, scroll, _footer } },
+ _edge
+ }
+ };
+ ClipToBounds = true;
+ HorizontalAlignment = HorizontalAlignment.Left;
+ Width = ExpandedWidth;
+ this.Token(BackgroundProperty, ShellToken.Card);
+ UpdateEdge();
+ }
+
+ public void Toggle() => IsCollapsed = !IsCollapsed;
+
+ // The width animates only once the sidebar is on screen, so it doesn't slide in at startup.
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ Refresh();
+ Transitions = new Transitions
+ {
+ new DoubleTransition { Property = WidthProperty, Duration = TimeSpan.FromMilliseconds(200), Easing = new CubicEaseOut() }
+ };
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+ Transitions = null;
+ }
+
+ private void SnapWidth()
+ {
+ var transitions = Transitions;
+ Transitions = null;
+ Width = IsCollapsed ? CollapsedWidth : ExpandedWidth;
+ Transitions = transitions;
+ }
+
+ private void UpdateEdge() =>
+ _edge.HorizontalAlignment = Side == SidebarSide.Left ? HorizontalAlignment.Right : HorizontalAlignment.Left;
+
+ private void UpdateSlots()
+ {
+ _header.Child = Header;
+ _header.IsVisible = Header != null;
+ _footer.Child = Footer;
+ _footer.IsVisible = Footer != null;
+ }
+
+ // Groups and items don't track the sidebar's state: the sidebar pushes it to them.
+ private void Refresh()
+ {
+ foreach (var group in this.FindDescendantsOfType())
+ group.Apply(IsCollapsed);
+ foreach (var item in this.FindDescendantsOfType())
+ item.Apply(isActive: !string.IsNullOrEmpty(item.Value) && item.Value == SelectedValue, collapsed: IsCollapsed);
+ }
+}
+
+// A labelled group of SidebarItems — the label is text-xs font-medium in the muted foreground.
+public class SidebarGroup : Border
+{
+ public static readonly StyledProperty LabelProperty =
+ AvaloniaProperty.Register(nameof(Label));
+
+ private readonly TextBlock _label;
+ private readonly StackPanel _items = new() { Spacing = 2 };
+ private bool _collapsed;
+
+ static SidebarGroup()
+ {
+ LabelProperty.Changed.AddClassHandler((g, _) => g.Update());
+ }
+
+ public string? Label
+ {
+ get => GetValue(LabelProperty);
+ set => SetValue(LabelProperty, value);
+ }
+
+ [Content]
+ public Controls Items => _items.Children;
+
+ public SidebarGroup()
+ {
+ _label = new TextBlock { FontSize = 12, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis };
+ _label.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+ var header = new Border { Child = _label, Height = 28, Padding = new Thickness(8, 0) };
+ Child = new StackPanel { Children = { header, _items } };
+ Update();
+ }
+
+ internal void Apply(bool collapsed)
+ {
+ _collapsed = collapsed;
+ Update();
+ }
+
+ private void Update()
+ {
+ _label.Text = Label ?? string.Empty;
+ if (_label.Parent is Control header) header.IsVisible = !_collapsed && !string.IsNullOrEmpty(Label);
+ }
+}
+
+// One row of a Sidebar — h-9 rounded-md px-2 text-sm: icon, text and an optional badge;
+// hover:bg-accent, and bg-accent font-medium when active. Collapsed, only the icon shows. A tab
+// stop that activates with Enter or Space.
+public class SidebarItem : Border, IShellFocusable
+{
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text));
+
+ // Identifies the item for Sidebar.SelectedValue.
+ public static readonly StyledProperty ValueProperty =
+ AvaloniaProperty.Register(nameof(Value));
+
+ public static readonly StyledProperty IconProperty =
+ AvaloniaProperty.Register(nameof(Icon));
+
+ // Short text at the end of the row, e.g. a count.
+ public static readonly StyledProperty BadgeProperty =
+ AvaloniaProperty.Register(nameof(Badge));
+
+ private readonly Grid _grid;
+ private readonly Icon _icon;
+ private readonly TextBlock _label;
+ private readonly TextBlock _badge;
+ private bool _active;
+ private bool _collapsed;
+
+ static SidebarItem()
+ {
+ TextProperty.Changed.AddClassHandler((i, _) => i.Update());
+ IconProperty.Changed.AddClassHandler((i, _) => i.Update());
+ BadgeProperty.Changed.AddClassHandler((i, _) => i.Update());
+ IsEnabledProperty.Changed.AddClassHandler((i, _) => i.Update());
+ }
+
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public string? Value
+ {
+ get => GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public string? Badge
+ {
+ get => GetValue(BadgeProperty);
+ set => SetValue(BadgeProperty, value);
+ }
+
+ public event EventHandler? Clicked;
+
+ public SidebarItem()
+ {
+ _icon = new Icon { Size = 16, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ _label = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis };
+ Grid.SetColumn(_label, 1);
+ _badge = new TextBlock { FontSize = 12, VerticalAlignment = VerticalAlignment.Center, IsVisible = false };
+ _badge.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+ Grid.SetColumn(_badge, 2);
+
+ _grid = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"), ColumnSpacing = 8, Children = { _icon, _label, _badge } };
+ Child = _grid;
+ Height = 36;
+ Padding = new Thickness(8, 0);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd);
+ Cursor = new Cursor(StandardCursorType.Hand);
+ ShellFocus.Ring(this, this);
+ Update();
+ }
+
+ protected override void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ Update();
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ Update();
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ Press();
+ e.Handled = true;
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if (e.Key is not (Key.Enter or Key.Space)) return;
+ Press();
+ e.Handled = true;
+ }
+
+ // Picks the item in code, as a click or Enter/Space would.
+ public void Press()
+ {
+ if (!IsEffectivelyEnabled) return;
+ if (!string.IsNullOrEmpty(Value) && this.FindParentOfType() is { } sidebar) sidebar.SelectedValue = Value;
+ Clicked?.Invoke(this, EventArgs.Empty);
+ }
+
+ internal void Apply(bool isActive, bool collapsed)
+ {
+ _active = isActive;
+ _collapsed = collapsed;
+ Update();
+ }
+
+ private void Update()
+ {
+ _label.Text = Text ?? string.Empty;
+ _label.FontWeight = _active ? FontWeight.Medium : FontWeight.Normal;
+ _label.IsVisible = !_collapsed;
+ _icon.Kind = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ _badge.Text = Badge ?? string.Empty;
+ _badge.IsVisible = !_collapsed && !string.IsNullOrEmpty(Badge);
+ // Collapsed, the icon column takes the row so the icon sits in the middle of the rail.
+ _grid.ColumnDefinitions[0].Width = _collapsed ? GridLength.Star : GridLength.Auto;
+ _grid.ColumnDefinitions[1].Width = _collapsed ? new GridLength(0) : GridLength.Star;
+ _grid.ColumnSpacing = _collapsed ? 0 : 8;
+ AutomationProperties.SetName(this, Text ?? string.Empty);
+
+ var foreground = _active ? ShellToken.AccentForeground : ShellToken.Foreground;
+ _label.Token(TextBlock.ForegroundProperty, foreground);
+ _icon.Token = foreground;
+
+ if (_active || (IsPointerOver && IsEffectivelyEnabled)) this.Token(BackgroundProperty, ShellToken.Accent);
+ else
+ {
+ // Transparent, not null, so the whole row stays hit-testable.
+ this.ClearToken(BackgroundProperty);
+ Background = Brushes.Transparent;
+ }
+ Opacity = IsEnabled ? 1.0 : 0.5;
+ }
+}
+
+public enum SidebarSide { Left, Right }
diff --git a/examples/Avalonia.Demo/MainWindow.axaml b/examples/Avalonia.Demo/MainWindow.axaml
index 36071f9..fecb549 100644
--- a/examples/Avalonia.Demo/MainWindow.axaml
+++ b/examples/Avalonia.Demo/MainWindow.axaml
@@ -77,6 +77,9 @@
+
+
+
@@ -516,6 +519,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/Avalonia.Demo/MainWindow.axaml.cs b/examples/Avalonia.Demo/MainWindow.axaml.cs
index 5850417..68d6c77 100644
--- a/examples/Avalonia.Demo/MainWindow.axaml.cs
+++ b/examples/Avalonia.Demo/MainWindow.axaml.cs
@@ -68,6 +68,27 @@ public MainWindow()
SourceCard.Clicked += (_, _) => DisplayChoice.Text = $"Link card: {SourceCard.Title}.";
FileTree.SelectedValueChanged += (_, value) => DisplayChoice.Text = $"Tree: {value}.";
+ DemoNavbar.SelectedValueChanged += (_, value) => LayoutChoice.Text = $"Navbar: {value}.";
+ DemoSidebar.SelectedValueChanged += (_, value) =>
+ {
+ SidebarPage.Text = string.IsNullOrEmpty(value) ? string.Empty : char.ToUpperInvariant(value[0]) + value.Substring(1);
+ LayoutChoice.Text = $"Sidebar: {value}.";
+ };
+ DemoSidebar.IsCollapsedChanged += (_, collapsed) =>
+ {
+ SidebarBrand.IsVisible = !collapsed;
+ LayoutChoice.Text = $"Sidebar: {(collapsed ? "collapsed" : "expanded")}.";
+ };
+ SidebarToggle.Clicked += (_, _) => DemoSidebar.Toggle();
+ SidebarLogOut.Clicked += (_, _) => LayoutChoice.Text = "Sidebar: Log out.";
+ DemoResizable.Resized += (_, _) =>
+ LayoutChoice.Text = $"Resizable: first panel is {ResizableFirst.Size / DemoResizable.Panels.OfType().Sum(p => p.Size):P0} of the width.";
+
+ DemoDateRange.RangeChanged += (_, _) =>
+ PickerChoice.Text = $"Date range: {DemoDateRange.StartDate:d} to {(DemoDateRange.EndDate.HasValue ? DemoDateRange.EndDate.Value.ToString("d") : "…")}.";
+ DemoFileUpload.FilesChanged += (_, files) =>
+ PickerChoice.Text = $"Files: {(files.Count == 0 ? "none" : string.Join(", ", files.Select(f => f.Name)))}.";
+
for (var i = 1; i <= 30; i++)
{
var tag = new TextBlock { Text = $"v1.2.0-beta.{31 - i}", FontSize = 14 };
diff --git a/examples/MAUI.Demo/Components/UI/Calendar.cs b/examples/MAUI.Demo/Components/UI/Calendar.cs
index a991325..b2d6708 100644
--- a/examples/MAUI.Demo/Components/UI/Calendar.cs
+++ b/examples/MAUI.Demo/Components/UI/Calendar.cs
@@ -5,7 +5,8 @@ namespace MAUI.Demo.Components.UI;
// Month calendar — prev/next header, weekday row, 6x7 day grid of 32px cells. Selected day is
// bg-primary, today is bg-accent, days outside the month are muted. Month names, weekday names
-// and the first day of the week follow the current culture.
+// and the first day of the week follow the current culture. RangeStart and RangeEnd tint a span
+// of days (DateRangePicker uses them).
// Usage:
public partial class Calendar : ContentView
{
@@ -21,12 +22,33 @@ public partial class Calendar : ContentView
BindableProperty.Create(nameof(MaximumDate), typeof(DateTime?), typeof(Calendar), null,
propertyChanged: (b, o, n) => ((Calendar)b).Refresh());
+ // The ends of a highlighted span: both ends are bg-primary, the days between bg-accent.
+ public static readonly BindableProperty RangeStartProperty =
+ BindableProperty.Create(nameof(RangeStart), typeof(DateTime?), typeof(Calendar), null,
+ propertyChanged: (b, o, n) => ((Calendar)b).Refresh());
+
+ public static readonly BindableProperty RangeEndProperty =
+ BindableProperty.Create(nameof(RangeEnd), typeof(DateTime?), typeof(Calendar), null,
+ propertyChanged: (b, o, n) => ((Calendar)b).Refresh());
+
public DateTime? SelectedDate
{
get => (DateTime?)GetValue(SelectedDateProperty);
set => SetValue(SelectedDateProperty, value);
}
+ public DateTime? RangeStart
+ {
+ get => (DateTime?)GetValue(RangeStartProperty);
+ set => SetValue(RangeStartProperty, value);
+ }
+
+ public DateTime? RangeEnd
+ {
+ get => (DateTime?)GetValue(RangeEndProperty);
+ set => SetValue(RangeEndProperty, value);
+ }
+
public DateTime? MinimumDate
{
get => (DateTime?)GetValue(MinimumDateProperty);
@@ -54,6 +76,7 @@ public DateTime DisplayMonth
private readonly Label _title;
private readonly List<(Border Box, Label Text)> _cells = new();
private readonly DateTime[] _dates = new DateTime[42];
+ private readonly bool[] _square = new bool[42];
private int _hovered = -1;
public Calendar()
@@ -192,12 +215,13 @@ private void StyleCell(int index)
var date = _dates[index];
var inMonth = date.Month == _month.Month;
var selectable = IsSelectable(date);
- var selected = SelectedDate?.Date == date;
+ var selected = SelectedDate?.Date == date || RangeStart?.Date == date || RangeEnd?.Date == date;
+ var inRange = !selected && RangeStart is { } from && RangeEnd is { } to && date > from.Date && date < to.Date;
var today = date == DateTime.Today;
if (selected)
box.Token(VisualElement.BackgroundColorProperty, ShellToken.Primary);
- else if (today || (index == _hovered && selectable))
+ else if (inRange || today || (index == _hovered && selectable))
box.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent);
else
{
@@ -205,8 +229,15 @@ private void StyleCell(int index)
box.BackgroundColor = Colors.Transparent;
}
+ // Days inside a span lose their corners, so the span reads as one band.
+ if (_square[index] != inRange)
+ {
+ _square[index] = inRange;
+ box.StrokeShape = new RoundRectangle { CornerRadius = inRange ? 0 : ShellTheme.RadiusMd };
+ }
+
text.Token(Label.TextColorProperty,
- selected ? ShellToken.PrimaryForeground : inMonth ? ShellToken.Foreground : ShellToken.MutedForeground);
- box.Opacity = !selectable ? 0.3 : inMonth || selected ? 1 : 0.5;
+ selected ? ShellToken.PrimaryForeground : inMonth || inRange ? ShellToken.Foreground : ShellToken.MutedForeground);
+ box.Opacity = !selectable ? 0.3 : inMonth || selected || inRange ? 1 : 0.5;
}
}
diff --git a/examples/MAUI.Demo/Components/UI/DateRangePicker.cs b/examples/MAUI.Demo/Components/UI/DateRangePicker.cs
new file mode 100644
index 0000000..2735eb5
--- /dev/null
+++ b/examples/MAUI.Demo/Components/UI/DateRangePicker.cs
@@ -0,0 +1,249 @@
+using System.Globalization;
+using Microsoft.Maui.Controls.Shapes;
+
+namespace MAUI.Demo.Components.UI;
+
+// Date range picker — a DatePicker-style trigger (h-10 rounded-md border) that opens a Calendar.
+// The first pick is the start, the second the end (picking an earlier day starts over); the
+// panel closes when the range is complete. The × clears it.
+// Usage:
+public partial class DateRangePicker : ContentView, IShellPopup
+{
+ public static readonly BindableProperty StartDateProperty =
+ BindableProperty.Create(nameof(StartDate), typeof(DateTime?), typeof(DateRangePicker), null, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((DateRangePicker)b).OnRangeChanged());
+
+ public static readonly BindableProperty EndDateProperty =
+ BindableProperty.Create(nameof(EndDate), typeof(DateTime?), typeof(DateRangePicker), null, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((DateRangePicker)b).OnRangeChanged());
+
+ public static readonly BindableProperty PlaceholderProperty =
+ BindableProperty.Create(nameof(Placeholder), typeof(string), typeof(DateRangePicker), "Pick a date range",
+ propertyChanged: (b, o, n) => ((DateRangePicker)b).UpdateTrigger());
+
+ // .NET date format string for the trigger text.
+ public static readonly BindableProperty FormatProperty =
+ BindableProperty.Create(nameof(Format), typeof(string), typeof(DateRangePicker), "MMM d, yyyy",
+ propertyChanged: (b, o, n) => ((DateRangePicker)b).UpdateTrigger());
+
+ // Shows the × that clears the range.
+ public static readonly BindableProperty AllowClearProperty =
+ BindableProperty.Create(nameof(AllowClear), typeof(bool), typeof(DateRangePicker), true,
+ propertyChanged: (b, o, n) => ((DateRangePicker)b).UpdateTrigger());
+
+ public static readonly BindableProperty MinimumDateProperty =
+ BindableProperty.Create(nameof(MinimumDate), typeof(DateTime?), typeof(DateRangePicker), null,
+ propertyChanged: (b, o, n) => ((DateRangePicker)b)._calendar.MinimumDate = (DateTime?)n);
+
+ public static readonly BindableProperty MaximumDateProperty =
+ BindableProperty.Create(nameof(MaximumDate), typeof(DateTime?), typeof(DateRangePicker), null,
+ propertyChanged: (b, o, n) => ((DateRangePicker)b)._calendar.MaximumDate = (DateTime?)n);
+
+ public DateTime? StartDate
+ {
+ get => (DateTime?)GetValue(StartDateProperty);
+ set => SetValue(StartDateProperty, value);
+ }
+
+ public DateTime? EndDate
+ {
+ get => (DateTime?)GetValue(EndDateProperty);
+ set => SetValue(EndDateProperty, value);
+ }
+
+ public string Placeholder
+ {
+ get => (string)GetValue(PlaceholderProperty);
+ set => SetValue(PlaceholderProperty, value);
+ }
+
+ public string Format
+ {
+ get => (string)GetValue(FormatProperty);
+ set => SetValue(FormatProperty, value);
+ }
+
+ public bool AllowClear
+ {
+ get => (bool)GetValue(AllowClearProperty);
+ set => SetValue(AllowClearProperty, value);
+ }
+
+ public DateTime? MinimumDate
+ {
+ get => (DateTime?)GetValue(MinimumDateProperty);
+ set => SetValue(MinimumDateProperty, value);
+ }
+
+ public DateTime? MaximumDate
+ {
+ get => (DateTime?)GetValue(MaximumDateProperty);
+ set => SetValue(MaximumDateProperty, value);
+ }
+
+ public bool IsOpen { get; private set; }
+
+ // Raised when StartDate or EndDate changes.
+ public event EventHandler? RangeChanged;
+
+ private readonly Border _trigger;
+ private readonly Label _text;
+ private readonly Grid _clear;
+ private readonly Calendar _calendar;
+ private readonly Border _panel;
+ private ShellPopupHandle? _handle;
+ private bool _clearTapped;
+
+ public DateRangePicker()
+ {
+ _text = new Label { FontSize = 14, VerticalOptions = LayoutOptions.Center, VerticalTextAlignment = TextAlignment.Center, LineBreakMode = LineBreakMode.TailTruncation };
+
+ var close = new Icon { Name = IconName.X, Size = 14, Token = ShellToken.MutedForeground, HorizontalOptions = LayoutOptions.Center, VerticalOptions = LayoutOptions.Center };
+ // 14px icon in a 24px tap target.
+ _clear = new Grid { WidthRequest = 24, HeightRequest = 24, BackgroundColor = Colors.Transparent, VerticalOptions = LayoutOptions.Center, IsVisible = false, Children = { close } };
+ SemanticProperties.SetDescription(_clear, "Clear dates");
+ var clearPointer = new PointerGestureRecognizer();
+ clearPointer.PointerEntered += (_, _) => close.Token = ShellToken.Foreground;
+ clearPointer.PointerExited += (_, _) => close.Token = ShellToken.MutedForeground;
+ _clear.GestureRecognizers.Add(clearPointer);
+ var clearTap = new TapGestureRecognizer();
+ clearTap.Tapped += (_, _) =>
+ {
+ if (!IsEnabled) return;
+ _clearTapped = true;
+ // Cleared on the next turn in case the tap never reaches the trigger.
+ Dispatcher.Dispatch(() => _clearTapped = false);
+ Clear();
+ };
+ _clear.GestureRecognizers.Add(clearTap);
+
+ var row = new Grid
+ {
+ ColumnSpacing = 8,
+ ColumnDefinitions =
+ {
+ new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto)
+ }
+ };
+ row.Add(new Icon { Name = IconName.Calendar, Size = 16, Token = ShellToken.MutedForeground, VerticalOptions = LayoutOptions.Center }, 0, 0);
+ row.Add(_text, 1, 0);
+ row.Add(_clear, 2, 0);
+
+ _trigger = new Border
+ {
+ Content = row,
+ HeightRequest = 40,
+ MinimumWidthRequest = 260,
+ Padding = new Thickness(12, 0, 8, 0),
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ BackgroundColor = Colors.Transparent
+ };
+ _trigger.Token(Border.StrokeProperty, ShellToken.Input);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) =>
+ {
+ // A tap on the × can reach the trigger too; it must not toggle the panel.
+ if (_clearTapped) { _clearTapped = false; return; }
+ if (!IsEnabled) return;
+ ShellFocus.FocusPressed(this);
+ SetOpen(!IsOpen);
+ };
+ _trigger.GestureRecognizers.Add(tap);
+ ShellFocus.MakeFocusable(this, () => { if (IsEnabled) SetOpen(!IsOpen); });
+
+ _calendar = new Calendar();
+ _calendar.DateSelected += (_, date) => Pick(date);
+
+ _panel = new Border
+ {
+ Content = _calendar,
+ Padding = new Thickness(12),
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ Shadow = ShellPopups.PanelShadow()
+ };
+ _panel.Token(VisualElement.BackgroundColorProperty, ShellToken.Popover);
+ _panel.Token(Border.StrokeProperty, ShellToken.Border);
+
+ Content = _trigger;
+ HorizontalOptions = LayoutOptions.Start;
+ UpdateTrigger();
+ }
+
+ public void SetOpen(bool open)
+ {
+ if (IsOpen == open) return;
+ IsOpen = open;
+ _trigger.Token(Border.StrokeProperty, open ? ShellToken.Ring : ShellToken.Input);
+ if (open)
+ {
+ UpdateCalendar();
+ _calendar.DisplayMonth = StartDate ?? DateTime.Today;
+ ShellPopups.Opened(this);
+ _handle = ShellPortal.ShowPopup(_trigger, _panel, new ShellPopupOptions { Owner = this, OnDismiss = Close });
+ }
+ else
+ {
+ ShellPopups.Closed(this);
+ var handle = _handle;
+ _handle = null;
+ if (handle != null) _ = handle.CloseAsync();
+ }
+ }
+
+ public void Close() => SetOpen(false);
+
+ public void Clear()
+ {
+ StartDate = null;
+ EndDate = null;
+ }
+
+ // The first pick (or a pick with a complete range, or one before the start) begins a new
+ // range; the next pick ends it.
+ private void Pick(DateTime date)
+ {
+ if (StartDate is not { } start || EndDate.HasValue || date < start.Date)
+ {
+ EndDate = null;
+ StartDate = date;
+ return;
+ }
+ EndDate = date;
+ SetOpen(false);
+ }
+
+ private void OnRangeChanged()
+ {
+ UpdateTrigger();
+ UpdateCalendar();
+ RangeChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void UpdateCalendar()
+ {
+ // The calendar marks the range itself; its own single selection stays empty.
+ _calendar.SelectedDate = null;
+ _calendar.RangeStart = StartDate;
+ _calendar.RangeEnd = EndDate;
+ }
+
+ private void UpdateTrigger()
+ {
+ var culture = CultureInfo.CurrentCulture;
+ var hasValue = StartDate.HasValue || EndDate.HasValue;
+ _text.Text = StartDate is { } start
+ ? EndDate is { } end ? $"{start.ToString(Format, culture)} – {end.ToString(Format, culture)}" : $"{start.ToString(Format, culture)} – …"
+ : Placeholder;
+ _text.Token(Label.TextColorProperty, hasValue ? ShellToken.Foreground : ShellToken.MutedForeground);
+ _clear.IsVisible = AllowClear && hasValue;
+ }
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == IsEnabledProperty.PropertyName)
+ Opacity = IsEnabled ? 1.0 : 0.5;
+ }
+}
diff --git a/examples/MAUI.Demo/Components/UI/FileUpload.cs b/examples/MAUI.Demo/Components/UI/FileUpload.cs
new file mode 100644
index 0000000..0eed5d5
--- /dev/null
+++ b/examples/MAUI.Demo/Components/UI/FileUpload.cs
@@ -0,0 +1,300 @@
+using Microsoft.Maui.Controls.Shapes;
+
+namespace MAUI.Demo.Components.UI;
+
+// File picker — a dashed drop zone (rounded-lg border-2 border-dashed p-8) with an upload icon
+// and a prompt; tapping it opens the platform file picker. On Windows, files can also be dropped
+// on it. The picked files are listed underneath with a × to remove each.
+//
+// Read a file with FileResult.OpenReadAsync().
+public partial class FileUpload : ContentView
+{
+ public static readonly BindableProperty TextProperty =
+ BindableProperty.Create(nameof(Text), typeof(string), typeof(FileUpload), "Drag and drop or click here to upload files",
+ propertyChanged: (b, o, n) => ((FileUpload)b).Update());
+
+ // More than one file can be picked; new picks are added to the list.
+ public static readonly BindableProperty MultipleProperty =
+ BindableProperty.Create(nameof(Multiple), typeof(bool), typeof(FileUpload), false,
+ propertyChanged: (b, o, n) => ((FileUpload)b).Update());
+
+ // The most files the list holds when Multiple is on. 0 means no limit.
+ public static readonly BindableProperty MaxFilesProperty =
+ BindableProperty.Create(nameof(MaxFiles), typeof(int), typeof(FileUpload), 0,
+ propertyChanged: (b, o, n) => ((FileUpload)b).Update());
+
+ // Which files the picker offers, e.g. FilePickerFileType.Images. Null offers every file.
+ public static readonly BindableProperty FileTypesProperty =
+ BindableProperty.Create(nameof(FileTypes), typeof(FilePickerFileType), typeof(FileUpload), null);
+
+ // The list of picked files under the drop zone.
+ public static readonly BindableProperty ShowFilesProperty =
+ BindableProperty.Create(nameof(ShowFiles), typeof(bool), typeof(FileUpload), true,
+ propertyChanged: (b, o, n) => ((FileUpload)b).Rebuild());
+
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public bool Multiple
+ {
+ get => (bool)GetValue(MultipleProperty);
+ set => SetValue(MultipleProperty, value);
+ }
+
+ public int MaxFiles
+ {
+ get => (int)GetValue(MaxFilesProperty);
+ set => SetValue(MaxFilesProperty, value);
+ }
+
+ public FilePickerFileType? FileTypes
+ {
+ get => (FilePickerFileType?)GetValue(FileTypesProperty);
+ set => SetValue(FileTypesProperty, value);
+ }
+
+ public bool ShowFiles
+ {
+ get => (bool)GetValue(ShowFilesProperty);
+ set => SetValue(ShowFilesProperty, value);
+ }
+
+ public IReadOnlyList Files => _files;
+
+ public event EventHandler>? FilesChanged;
+
+ private readonly List _files = new();
+ private readonly Border _zone;
+ private readonly Label _text;
+ private readonly Label _hint;
+ private readonly VerticalStackLayout _list;
+ private bool _hovered;
+ private bool _dragOver;
+ private bool _picking;
+
+ public FileUpload()
+ {
+ _text = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold, HorizontalTextAlignment = TextAlignment.Center };
+ _text.Token(Label.TextColorProperty, ShellToken.Foreground);
+ _hint = new Label { FontSize = 12, HorizontalTextAlignment = TextAlignment.Center, IsVisible = false };
+ _hint.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+
+ _zone = new Border
+ {
+ Content = new VerticalStackLayout
+ {
+ Spacing = 4,
+ Children =
+ {
+ new Icon { Name = IconName.Upload, Size = 32, Token = ShellToken.MutedForeground, HorizontalOptions = LayoutOptions.Center, Margin = new Thickness(0, 0, 0, 8) },
+ _text,
+ _hint
+ }
+ },
+ Padding = new Thickness(32),
+ StrokeThickness = 2,
+ StrokeDashArray = new DoubleCollection { 3, 3 },
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusLg },
+ BackgroundColor = Colors.Transparent
+ };
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => { _hovered = true; Paint(); };
+ pointer.PointerExited += (_, _) => { _hovered = false; Paint(); };
+ _zone.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { ShellFocus.FocusPressed(this); _ = PickAsync(); };
+ _zone.GestureRecognizers.Add(tap);
+ ShellFocus.MakeFocusable(this, () => _ = PickAsync());
+
+ var drop = new DropGestureRecognizer();
+ drop.DragOver += (_, e) =>
+ {
+ if (!IsEnabled) return;
+ e.AcceptedOperation = DataPackageOperation.Copy;
+ _dragOver = true;
+ Paint();
+ };
+ drop.DragLeave += (_, _) => { _dragOver = false; Paint(); };
+ drop.Drop += OnDrop;
+ _zone.GestureRecognizers.Add(drop);
+
+ _list = new VerticalStackLayout { Spacing = 4, Margin = new Thickness(0, 8, 0, 0), IsVisible = false };
+ Content = new VerticalStackLayout { Spacing = 0, Children = { _zone, _list } };
+ Update();
+ }
+
+ // Opens the platform file picker and adds what was picked.
+ public async Task PickAsync()
+ {
+ if (!IsEnabled || _picking) return;
+ _picking = true;
+ try
+ {
+ var options = new PickOptions { FileTypes = FileTypes };
+ if (Multiple)
+ {
+ var picked = await FilePicker.Default.PickMultipleAsync(options);
+ if (picked != null) Add(picked.Where(f => f != null)!);
+ }
+ else
+ {
+ var picked = await FilePicker.Default.PickAsync(options);
+ if (picked != null) Add(new[] { picked });
+ }
+ }
+ catch (Exception)
+ {
+ // The picker was cancelled or isn't available here.
+ }
+ finally
+ {
+ _picking = false;
+ }
+ }
+
+ // Adds files as a pick or a drop would: one file replaces the current one unless Multiple
+ // is on, and the list never grows past MaxFiles.
+ public void Add(IEnumerable files)
+ {
+ var changed = false;
+ foreach (var file in files)
+ {
+ if (!Multiple)
+ {
+ _files.Clear();
+ _files.Add(file);
+ changed = true;
+ break;
+ }
+ if (MaxFiles > 0 && _files.Count >= MaxFiles) break;
+ if (_files.Any(f => f.FullPath == file.FullPath)) continue;
+ _files.Add(file);
+ changed = true;
+ }
+ if (changed) OnFilesChanged();
+ }
+
+ public void Remove(FileResult file)
+ {
+ if (_files.Remove(file)) OnFilesChanged();
+ }
+
+ public void Clear()
+ {
+ if (_files.Count == 0) return;
+ _files.Clear();
+ OnFilesChanged();
+ }
+
+ private void OnFilesChanged()
+ {
+ Rebuild();
+ FilesChanged?.Invoke(this, _files.ToList());
+ }
+
+ private async void OnDrop(object? sender, DropEventArgs e)
+ {
+ _dragOver = false;
+ Paint();
+ if (!IsEnabled) return;
+#if WINDOWS
+ try
+ {
+ if (e.PlatformArgs?.DragEventArgs.DataView is not { } data ||
+ !data.Contains(Windows.ApplicationModel.DataTransfer.StandardDataFormats.StorageItems)) return;
+ var items = await data.GetStorageItemsAsync();
+ Add(items.OfType().Select(f => new FileResult(f.Path)));
+ }
+ catch (Exception)
+ {
+ // The dropped data couldn't be read as files.
+ }
+#else
+ await Task.CompletedTask;
+#endif
+ }
+
+ private void Rebuild()
+ {
+ _list.Children.Clear();
+ _list.IsVisible = ShowFiles && _files.Count > 0;
+ if (!ShowFiles) return;
+ foreach (var file in _files)
+ _list.Children.Add(CreateRow(file));
+ }
+
+ // rounded-md border px-3 h-10: file icon, name, × to remove.
+ private View CreateRow(FileResult file)
+ {
+ var name = new Label { Text = file.FileName, FontSize = 14, VerticalTextAlignment = TextAlignment.Center, VerticalOptions = LayoutOptions.Center, LineBreakMode = LineBreakMode.MiddleTruncation };
+ name.Token(Label.TextColorProperty, ShellToken.Foreground);
+ var close = new Icon { Name = IconName.X, Size = 14, Token = ShellToken.MutedForeground, HorizontalOptions = LayoutOptions.Center, VerticalOptions = LayoutOptions.Center };
+ // 14px icon in a 24px tap target.
+ var remove = new Grid { WidthRequest = 24, HeightRequest = 24, BackgroundColor = Colors.Transparent, VerticalOptions = LayoutOptions.Center, Children = { close } };
+ SemanticProperties.SetDescription(remove, $"Remove {file.FileName}");
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => close.Token = ShellToken.Destructive;
+ pointer.PointerExited += (_, _) => close.Token = ShellToken.MutedForeground;
+ remove.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { if (IsEnabled) Remove(file); };
+ remove.GestureRecognizers.Add(tap);
+
+ var grid = new Grid
+ {
+ ColumnSpacing = 8,
+ ColumnDefinitions =
+ {
+ new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto)
+ }
+ };
+ grid.Add(new Icon { Name = IconName.File, Size = 16, Token = ShellToken.MutedForeground, VerticalOptions = LayoutOptions.Center }, 0, 0);
+ grid.Add(name, 1, 0);
+ grid.Add(remove, 2, 0);
+
+ var row = new Border
+ {
+ Content = grid,
+ HeightRequest = 40,
+ Padding = new Thickness(12, 0, 8, 0),
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }
+ };
+ row.Token(Border.StrokeProperty, ShellToken.Border);
+ return row;
+ }
+
+ private void Update()
+ {
+ _text.Text = Text ?? string.Empty;
+ _hint.Text = MaxFiles > 1 ? $"Select up to {MaxFiles} files" : string.Empty;
+ _hint.IsVisible = Multiple && MaxFiles > 1;
+ SemanticProperties.SetDescription(this, Text ?? string.Empty);
+ Paint();
+ }
+
+ private void Paint()
+ {
+ var active = _dragOver || (_hovered && IsEnabled);
+ _zone.Token(Border.StrokeProperty, _dragOver ? ShellToken.Primary : active ? ShellToken.Ring : ShellToken.Border);
+ if (active) _zone.Token(VisualElement.BackgroundColorProperty, ShellToken.Muted);
+ else
+ {
+ _zone.ClearValue(VisualElement.BackgroundColorProperty);
+ _zone.BackgroundColor = Colors.Transparent;
+ }
+ }
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == IsEnabledProperty.PropertyName && _zone != null)
+ {
+ Opacity = IsEnabled ? 1.0 : 0.5;
+ Paint();
+ }
+ }
+}
diff --git a/examples/MAUI.Demo/Components/UI/Navbar.cs b/examples/MAUI.Demo/Components/UI/Navbar.cs
new file mode 100644
index 0000000..901581c
--- /dev/null
+++ b/examples/MAUI.Demo/Components/UI/Navbar.cs
@@ -0,0 +1,331 @@
+using System.Collections.ObjectModel;
+using Microsoft.Maui.Controls.Shapes;
+
+namespace MAUI.Demo.Components.UI;
+
+// Top navigation bar — h-14 border-b bg-background: a brand (icon and title), links, and actions
+// at the end. Narrower than CollapseBelow, the links move into a menu that a button opens under
+// the bar.
+//
+//
+//
+//
+//
+//
+//
+[ContentProperty(nameof(Items))]
+public partial class Navbar : ContentView
+{
+ public static readonly BindableProperty TitleProperty =
+ BindableProperty.Create(nameof(Title), typeof(string), typeof(Navbar), string.Empty,
+ propertyChanged: (b, o, n) => ((Navbar)b).UpdateBrand());
+
+ public static readonly BindableProperty IconProperty =
+ BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(Navbar), IconName.None,
+ propertyChanged: (b, o, n) => ((Navbar)b).UpdateBrand());
+
+ // Replaces the icon and title.
+ public static readonly BindableProperty BrandProperty =
+ BindableProperty.Create(nameof(Brand), typeof(View), typeof(Navbar), null,
+ propertyChanged: (b, o, n) => ((Navbar)b).UpdateBrand());
+
+ // Shown at the end of the bar (buttons, an avatar, a theme toggle).
+ public static readonly BindableProperty ActionsProperty =
+ BindableProperty.Create(nameof(Actions), typeof(View), typeof(Navbar), null,
+ propertyChanged: (b, o, n) => ((Navbar)b)._actions.Content = n as View);
+
+ // The active item's Value.
+ public static readonly BindableProperty SelectedValueProperty =
+ BindableProperty.Create(nameof(SelectedValue), typeof(string), typeof(Navbar), null, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((Navbar)b).OnSelectedValueChanged());
+
+ // Below this width the links collapse into the menu. 0 never collapses.
+ public static readonly BindableProperty CollapseBelowProperty =
+ BindableProperty.Create(nameof(CollapseBelow), typeof(double), typeof(Navbar), 640d,
+ propertyChanged: (b, o, n) => ((Navbar)b).UpdateLayout());
+
+ public static readonly BindableProperty IsMenuOpenProperty =
+ BindableProperty.Create(nameof(IsMenuOpen), typeof(bool), typeof(Navbar), false, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((Navbar)b).UpdateLayout());
+
+ public string Title
+ {
+ get => (string)GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => (IconName)GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public View? Brand
+ {
+ get => (View?)GetValue(BrandProperty);
+ set => SetValue(BrandProperty, value);
+ }
+
+ public View? Actions
+ {
+ get => (View?)GetValue(ActionsProperty);
+ set => SetValue(ActionsProperty, value);
+ }
+
+ public string? SelectedValue
+ {
+ get => (string?)GetValue(SelectedValueProperty);
+ set => SetValue(SelectedValueProperty, value);
+ }
+
+ public double CollapseBelow
+ {
+ get => (double)GetValue(CollapseBelowProperty);
+ set => SetValue(CollapseBelowProperty, value);
+ }
+
+ public bool IsMenuOpen
+ {
+ get => (bool)GetValue(IsMenuOpenProperty);
+ set => SetValue(IsMenuOpenProperty, value);
+ }
+
+ public IList Items => _items;
+
+ public event EventHandler? SelectedValueChanged;
+
+ private readonly ObservableCollection _items = new();
+ private readonly Icon _brandIcon;
+ private readonly Label _title;
+ private readonly ContentView _brand;
+ private readonly HorizontalStackLayout _brandRow;
+ private readonly HorizontalStackLayout _links;
+ private readonly ContentView _actions;
+ private readonly Border _menuButton;
+ private readonly Icon _menuIcon;
+ private readonly VerticalStackLayout _menu;
+ private bool _collapsed;
+
+ public Navbar()
+ {
+ _brandIcon = new Icon { Size = 20, Token = ShellToken.Foreground, VerticalOptions = LayoutOptions.Center, IsVisible = false };
+ _title = new Label { FontSize = 16, FontAttributes = FontAttributes.Bold, VerticalTextAlignment = TextAlignment.Center, LineBreakMode = LineBreakMode.NoWrap };
+ _title.Token(Label.TextColorProperty, ShellToken.Foreground);
+ _brandRow = new HorizontalStackLayout { Spacing = 8, Children = { _brandIcon, _title } };
+ _brand = new ContentView { VerticalOptions = LayoutOptions.Center, Content = _brandRow };
+ _links = new HorizontalStackLayout { Spacing = 4, VerticalOptions = LayoutOptions.Center, Margin = new Thickness(16, 0, 0, 0) };
+ _actions = new ContentView { VerticalOptions = LayoutOptions.Center };
+
+ _menuIcon = new Icon { Name = IconName.Menu, Size = 18, Token = ShellToken.Foreground };
+ _menuButton = new Border
+ {
+ Content = _menuIcon,
+ WidthRequest = 36,
+ HeightRequest = 36,
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ BackgroundColor = Colors.Transparent,
+ VerticalOptions = LayoutOptions.Center,
+ Margin = new Thickness(4, 0, 0, 0),
+ IsVisible = false
+ };
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => _menuButton.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent);
+ pointer.PointerExited += (_, _) => { _menuButton.ClearValue(VisualElement.BackgroundColorProperty); _menuButton.BackgroundColor = Colors.Transparent; };
+ _menuButton.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => IsMenuOpen = !IsMenuOpen;
+ _menuButton.GestureRecognizers.Add(tap);
+
+ var bar = new Grid
+ {
+ HeightRequest = 56,
+ Padding = new Thickness(16, 0),
+ ColumnDefinitions =
+ {
+ new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star),
+ new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Auto)
+ }
+ };
+ bar.Add(_brand, 0, 0);
+ bar.Add(_links, 1, 0);
+ bar.Add(_actions, 2, 0);
+ bar.Add(_menuButton, 3, 0);
+
+ _menu = new VerticalStackLayout { Spacing = 2, Padding = new Thickness(12, 0, 12, 12), IsVisible = false };
+ var edge = new BoxView { HeightRequest = 1, BackgroundColor = Colors.Transparent };
+ edge.Token(BoxView.ColorProperty, ShellToken.Border);
+
+ var root = new VerticalStackLayout { Spacing = 0, Children = { bar, _menu, edge } };
+ root.Token(VisualElement.BackgroundColorProperty, ShellToken.Background);
+ Content = root;
+
+ _items.CollectionChanged += (_, _) => UpdateLayout(force: true);
+ SizeChanged += (_, _) => UpdateLayout();
+ UpdateBrand();
+ }
+
+ internal void Select(NavbarItem item)
+ {
+ if (!string.IsNullOrEmpty(item.Value)) SelectedValue = item.Value;
+ IsMenuOpen = false;
+ }
+
+ private void OnSelectedValueChanged()
+ {
+ Refresh();
+ SelectedValueChanged?.Invoke(this, SelectedValue);
+ }
+
+ private void UpdateBrand()
+ {
+ _brandIcon.Name = Icon;
+ _brandIcon.IsVisible = Icon != IconName.None;
+ _title.Text = Title ?? string.Empty;
+ _title.IsVisible = !string.IsNullOrEmpty(Title);
+ _brand.Content = Brand ?? _brandRow;
+ }
+
+ private void UpdateLayout() => UpdateLayout(force: false);
+
+ // The links live in the bar or, when collapsed, in the menu under it.
+ private void UpdateLayout(bool force)
+ {
+ var collapsed = CollapseBelow > 0 && Width > 0 && Width < CollapseBelow && _items.Count > 0;
+ if (collapsed != _collapsed || force)
+ {
+ _collapsed = collapsed;
+ _links.Children.Clear();
+ _menu.Children.Clear();
+ foreach (var item in _items)
+ {
+ if (collapsed) _menu.Children.Add(item);
+ else _links.Children.Add(item);
+ }
+ }
+ _menuButton.IsVisible = _collapsed;
+ _menu.IsVisible = _collapsed && IsMenuOpen;
+ _menuIcon.Name = IsMenuOpen && _collapsed ? IconName.X : IconName.Menu;
+ SemanticProperties.SetDescription(_menuButton, IsMenuOpen ? "Close menu" : "Open menu");
+ Refresh();
+ }
+
+ // Items don't track the bar's state: the bar pushes it to them.
+ private void Refresh()
+ {
+ foreach (var item in _items)
+ item.Apply(isActive: !string.IsNullOrEmpty(item.Value) && item.Value == SelectedValue, inMenu: _collapsed);
+ }
+}
+
+// A link in a Navbar — text-sm font-medium, muted until hovered or active.
+public partial class NavbarItem : ContentView
+{
+ public static readonly BindableProperty TextProperty =
+ BindableProperty.Create(nameof(Text), typeof(string), typeof(NavbarItem), string.Empty,
+ propertyChanged: (b, o, n) => ((NavbarItem)b).Update());
+
+ // Identifies the item for Navbar.SelectedValue.
+ public static readonly BindableProperty ValueProperty =
+ BindableProperty.Create(nameof(Value), typeof(string), typeof(NavbarItem), null);
+
+ public static readonly BindableProperty IconProperty =
+ BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(NavbarItem), IconName.None,
+ propertyChanged: (b, o, n) => ((NavbarItem)b).Update());
+
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public string? Value
+ {
+ get => (string?)GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => (IconName)GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public event EventHandler? Clicked;
+
+ private readonly Border _border;
+ private readonly Icon _icon;
+ private readonly Label _label;
+ private bool _active;
+ private bool _hovered;
+
+ public NavbarItem()
+ {
+ _icon = new Icon { Size = 16, IsVisible = false, VerticalOptions = LayoutOptions.Center };
+ _label = new Label { FontSize = 14, VerticalTextAlignment = TextAlignment.Center, VerticalOptions = LayoutOptions.Center, LineBreakMode = LineBreakMode.NoWrap };
+ _border = new Border
+ {
+ Content = new HorizontalStackLayout { Spacing = 8, VerticalOptions = LayoutOptions.Center, Children = { _icon, _label } },
+ HeightRequest = 36,
+ Padding = new Thickness(12, 0),
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ BackgroundColor = Colors.Transparent
+ };
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => { _hovered = true; Update(); };
+ pointer.PointerExited += (_, _) => { _hovered = false; Update(); };
+ _border.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { ShellFocus.FocusPressed(this); Press(); };
+ _border.GestureRecognizers.Add(tap);
+ ShellFocus.MakeFocusable(this, Press);
+
+ Content = _border;
+ Update();
+ }
+
+ private void Press()
+ {
+ if (!IsEnabled) return;
+ this.FindParentOfType()?.Select(this);
+ Clicked?.Invoke(this, EventArgs.Empty);
+ }
+
+ internal void Apply(bool isActive, bool inMenu)
+ {
+ _active = isActive;
+ // In the menu each link is a full-width row.
+ HorizontalOptions = inMenu ? LayoutOptions.Fill : LayoutOptions.Start;
+ Update();
+ }
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == IsEnabledProperty.PropertyName)
+ Opacity = IsEnabled ? 1.0 : 0.5;
+ }
+
+ private void Update()
+ {
+ _label.Text = Text ?? string.Empty;
+ _label.FontAttributes = _active ? FontAttributes.Bold : FontAttributes.None;
+ _icon.Name = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+
+ var foreground = _active || _hovered ? ShellToken.Foreground : ShellToken.MutedForeground;
+ _label.Token(Label.TextColorProperty, foreground);
+ _icon.Token = foreground;
+
+ if (_active)
+ _border.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent);
+ else if (_hovered && IsEnabled)
+ _border.Token(VisualElement.BackgroundColorProperty, ShellToken.Muted);
+ else
+ {
+ _border.ClearValue(VisualElement.BackgroundColorProperty);
+ _border.BackgroundColor = Colors.Transparent;
+ }
+ }
+}
diff --git a/examples/MAUI.Demo/Components/UI/Resizable.cs b/examples/MAUI.Demo/Components/UI/Resizable.cs
new file mode 100644
index 0000000..5001659
--- /dev/null
+++ b/examples/MAUI.Demo/Components/UI/Resizable.cs
@@ -0,0 +1,225 @@
+using System.Collections.ObjectModel;
+using Microsoft.Maui.Controls.Shapes;
+
+namespace MAUI.Demo.Components.UI;
+
+// Resizable panels — panels side by side (or stacked) with a hairline between them; drag the
+// hairline to move space from one neighbor to the other. Give the group a size.
+//
+// ...any view...
+// ...
+//
+[ContentProperty(nameof(Panels))]
+public partial class Resizable : ContentView
+{
+ public static readonly BindableProperty OrientationProperty =
+ BindableProperty.Create(nameof(Orientation), typeof(StackOrientation), typeof(Resizable), StackOrientation.Horizontal,
+ propertyChanged: (b, o, n) => ((Resizable)b).Rebuild());
+
+ // The short bar on each divider. The divider can be dragged with or without it.
+ public static readonly BindableProperty ShowHandleProperty =
+ BindableProperty.Create(nameof(ShowHandle), typeof(bool), typeof(Resizable), true,
+ propertyChanged: (b, o, n) => ((Resizable)b).Rebuild());
+
+ public StackOrientation Orientation
+ {
+ get => (StackOrientation)GetValue(OrientationProperty);
+ set => SetValue(OrientationProperty, value);
+ }
+
+ public bool ShowHandle
+ {
+ get => (bool)GetValue(ShowHandleProperty);
+ set => SetValue(ShowHandleProperty, value);
+ }
+
+ public IList Panels => _panels;
+
+ // Raised when a drag ends.
+ public event EventHandler? Resized;
+
+ // The divider is a hairline; its grab area reaches this far into the panels on each side.
+ private const double GrabReach = 4;
+
+ private readonly ObservableCollection _panels = new();
+ private readonly Grid _grid;
+ private double _startBefore;
+ private double _startAfter;
+
+ public Resizable()
+ {
+ _grid = new Grid { IsClippedToBounds = true };
+ Content = _grid;
+ _panels.CollectionChanged += (_, _) => Rebuild();
+ }
+
+ private bool IsHorizontal => Orientation == StackOrientation.Horizontal;
+
+ private void Rebuild()
+ {
+ _grid.Children.Clear();
+ _grid.ColumnDefinitions.Clear();
+ _grid.RowDefinitions.Clear();
+
+ for (var i = 0; i < _panels.Count; i++)
+ {
+ var panel = _panels[i];
+ panel.SizeSet -= ApplySizes;
+ panel.SizeSet += ApplySizes;
+ if (i > 0) AddTrack(new GridLength(1), CreateHandle(i - 1));
+ AddTrack(new GridLength(Math.Max(0.01, panel.Size), GridUnitType.Star), panel);
+ }
+ }
+
+ private void AddTrack(GridLength length, View view)
+ {
+ if (IsHorizontal)
+ {
+ _grid.ColumnDefinitions.Add(new ColumnDefinition(length));
+ _grid.Add(view, _grid.ColumnDefinitions.Count - 1, 0);
+ }
+ else
+ {
+ _grid.RowDefinitions.Add(new RowDefinition(length));
+ _grid.Add(view, 0, _grid.RowDefinitions.Count - 1);
+ }
+ }
+
+ // Panel i sits on track 2i, the divider after it on track 2i + 1.
+ private void ApplySizes()
+ {
+ for (var i = 0; i < _panels.Count; i++)
+ {
+ var length = new GridLength(Math.Max(0.01, _panels[i].Size), GridUnitType.Star);
+ if (IsHorizontal && 2 * i < _grid.ColumnDefinitions.Count) _grid.ColumnDefinitions[2 * i].Width = length;
+ else if (!IsHorizontal && 2 * i < _grid.RowDefinitions.Count) _grid.RowDefinitions[2 * i].Height = length;
+ }
+ }
+
+ private View CreateHandle(int index)
+ {
+ var line = new BoxView { BackgroundColor = Colors.Transparent };
+ line.Token(BoxView.ColorProperty, ShellToken.Border);
+ if (IsHorizontal)
+ {
+ line.WidthRequest = 1;
+ line.HorizontalOptions = LayoutOptions.Center;
+ }
+ else
+ {
+ line.HeightRequest = 1;
+ line.VerticalOptions = LayoutOptions.Center;
+ }
+
+ var handle = new Grid
+ {
+ BackgroundColor = Colors.Transparent,
+ Margin = IsHorizontal ? new Thickness(-GrabReach, 0) : new Thickness(0, -GrabReach),
+ ZIndex = 1,
+ Children = { line }
+ };
+ if (ShowHandle)
+ {
+ // A short rounded bar across the hairline.
+ var grip = new Border
+ {
+ WidthRequest = IsHorizontal ? 5 : 28,
+ HeightRequest = IsHorizontal ? 28 : 5,
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = 2.5 },
+ HorizontalOptions = LayoutOptions.Center,
+ VerticalOptions = LayoutOptions.Center
+ };
+ grip.Token(VisualElement.BackgroundColorProperty, ShellToken.Border);
+ grip.Token(Border.StrokeProperty, ShellToken.Ring);
+ handle.Children.Add(grip);
+ }
+ SemanticProperties.SetDescription(handle, "Resize");
+
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => line.Token(BoxView.ColorProperty, ShellToken.Ring);
+ pointer.PointerExited += (_, _) => line.Token(BoxView.ColorProperty, ShellToken.Border);
+ handle.GestureRecognizers.Add(pointer);
+
+ var pan = new PanGestureRecognizer();
+ pan.PanUpdated += (_, e) => OnPan(index, e);
+ handle.GestureRecognizers.Add(pan);
+ return handle;
+ }
+
+ private void OnPan(int index, PanUpdatedEventArgs e)
+ {
+ if (index + 1 >= _panels.Count) return;
+ var before = _panels[index];
+ var after = _panels[index + 1];
+ switch (e.StatusType)
+ {
+ case GestureStatus.Started:
+ _startBefore = IsHorizontal ? before.Width : before.Height;
+ _startAfter = IsHorizontal ? after.Width : after.Height;
+ break;
+ case GestureStatus.Running:
+ var total = _startBefore + _startAfter;
+ if (total <= 0) return;
+ // Space only moves between the two neighbors, and neither goes under its minimum.
+ var low = Math.Min(before.MinSize, total / 2);
+ var high = total - Math.Min(after.MinSize, total / 2);
+ var size = Math.Clamp(_startBefore + (IsHorizontal ? e.TotalX : e.TotalY), low, high);
+ var weight = before.Size + after.Size;
+ before.SetSize(weight * size / total);
+ after.SetSize(weight - before.Size);
+ ApplySizes();
+ break;
+ case GestureStatus.Completed:
+ case GestureStatus.Canceled:
+ Resized?.Invoke(this, EventArgs.Empty);
+ break;
+ }
+ }
+}
+
+// One panel of a Resizable. Size is its share of the space (2 is twice as much as 1).
+public partial class ResizablePanel : ContentView
+{
+ public static readonly BindableProperty SizeProperty =
+ BindableProperty.Create(nameof(Size), typeof(double), typeof(ResizablePanel), 1d, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((ResizablePanel)b).OnSizeSet());
+
+ // The smallest the panel can be dragged to, in device-independent pixels.
+ public static readonly BindableProperty MinSizeProperty =
+ BindableProperty.Create(nameof(MinSize), typeof(double), typeof(ResizablePanel), 48d);
+
+ public double Size
+ {
+ get => (double)GetValue(SizeProperty);
+ set => SetValue(SizeProperty, value);
+ }
+
+ public double MinSize
+ {
+ get => (double)GetValue(MinSizeProperty);
+ set => SetValue(MinSizeProperty, value);
+ }
+
+ internal event Action? SizeSet;
+
+ private bool _dragging;
+
+ public ResizablePanel()
+ {
+ IsClippedToBounds = true;
+ }
+
+ // Set by the group during a drag; it applies the new sizes itself.
+ internal void SetSize(double size)
+ {
+ _dragging = true;
+ Size = size;
+ _dragging = false;
+ }
+
+ private void OnSizeSet()
+ {
+ if (!_dragging) SizeSet?.Invoke();
+ }
+}
diff --git a/examples/MAUI.Demo/Components/UI/Sidebar.cs b/examples/MAUI.Demo/Components/UI/Sidebar.cs
new file mode 100644
index 0000000..512a79d
--- /dev/null
+++ b/examples/MAUI.Demo/Components/UI/Sidebar.cs
@@ -0,0 +1,376 @@
+using Microsoft.Maui.Controls.Shapes;
+
+namespace MAUI.Demo.Components.UI;
+
+// App sidebar — a w-64 column (bg-card, border on its inner edge) with a header, scrolling
+// groups of items and a footer. Collapsed, it is a w-14 rail that shows the icons only. Put it in
+// a column next to your content, or inside a Sheet on phones.
+//
+// ...any view...
+//
+//
+//
+//
+// ...
+//
+// Collapse it with IsCollapsed or Toggle().
+[ContentProperty(nameof(Groups))]
+public partial class Sidebar : ContentView
+{
+ public static readonly BindableProperty IsCollapsedProperty =
+ BindableProperty.Create(nameof(IsCollapsed), typeof(bool), typeof(Sidebar), false, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((Sidebar)b).OnCollapsedChanged());
+
+ // The active item's Value.
+ public static readonly BindableProperty SelectedValueProperty =
+ BindableProperty.Create(nameof(SelectedValue), typeof(string), typeof(Sidebar), null, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((Sidebar)b).OnSelectedValueChanged());
+
+ public static readonly BindableProperty ExpandedWidthProperty =
+ BindableProperty.Create(nameof(ExpandedWidth), typeof(double), typeof(Sidebar), 256d,
+ propertyChanged: (b, o, n) => ((Sidebar)b).ApplyWidth(animate: false));
+
+ public static readonly BindableProperty CollapsedWidthProperty =
+ BindableProperty.Create(nameof(CollapsedWidth), typeof(double), typeof(Sidebar), 56d,
+ propertyChanged: (b, o, n) => ((Sidebar)b).ApplyWidth(animate: false));
+
+ // Which edge of the window it sits on; the border is drawn on the other edge.
+ public static readonly BindableProperty SideProperty =
+ BindableProperty.Create(nameof(Side), typeof(SidebarSide), typeof(Sidebar), SidebarSide.Left,
+ propertyChanged: (b, o, n) => ((Sidebar)b).UpdateEdge());
+
+ public static readonly BindableProperty HeaderProperty =
+ BindableProperty.Create(nameof(Header), typeof(View), typeof(Sidebar), null,
+ propertyChanged: (b, o, n) => ((Sidebar)b).UpdateSlots());
+
+ public static readonly BindableProperty FooterProperty =
+ BindableProperty.Create(nameof(Footer), typeof(View), typeof(Sidebar), null,
+ propertyChanged: (b, o, n) => ((Sidebar)b).UpdateSlots());
+
+ public bool IsCollapsed
+ {
+ get => (bool)GetValue(IsCollapsedProperty);
+ set => SetValue(IsCollapsedProperty, value);
+ }
+
+ public string? SelectedValue
+ {
+ get => (string?)GetValue(SelectedValueProperty);
+ set => SetValue(SelectedValueProperty, value);
+ }
+
+ public double ExpandedWidth
+ {
+ get => (double)GetValue(ExpandedWidthProperty);
+ set => SetValue(ExpandedWidthProperty, value);
+ }
+
+ public double CollapsedWidth
+ {
+ get => (double)GetValue(CollapsedWidthProperty);
+ set => SetValue(CollapsedWidthProperty, value);
+ }
+
+ public SidebarSide Side
+ {
+ get => (SidebarSide)GetValue(SideProperty);
+ set => SetValue(SideProperty, value);
+ }
+
+ public View? Header
+ {
+ get => (View?)GetValue(HeaderProperty);
+ set => SetValue(HeaderProperty, value);
+ }
+
+ public View? Footer
+ {
+ get => (View?)GetValue(FooterProperty);
+ set => SetValue(FooterProperty, value);
+ }
+
+ public IList Groups => _groups.Children;
+
+ public event EventHandler? IsCollapsedChanged;
+ public event EventHandler? SelectedValueChanged;
+
+ private const string WidthAnimation = "SidebarWidth";
+
+ private readonly ContentView _header;
+ private readonly ContentView _footer;
+ private readonly VerticalStackLayout _groups;
+ private readonly BoxView _edge;
+
+ public Sidebar()
+ {
+ _header = new ContentView { Padding = new Thickness(8), IsVisible = false };
+ _footer = new ContentView { Padding = new Thickness(8), IsVisible = false };
+ _groups = new VerticalStackLayout { Spacing = 8, Padding = new Thickness(8) };
+ _groups.ChildAdded += (_, _) => Refresh();
+ _edge = new BoxView { WidthRequest = 1, BackgroundColor = Colors.Transparent };
+ _edge.Token(BoxView.ColorProperty, ShellToken.Border);
+
+ var column = new Grid
+ {
+ RowDefinitions = { new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Star), new RowDefinition(GridLength.Auto) }
+ };
+ column.Add(_header, 0, 0);
+ column.Add(new ScrollView { Content = _groups, VerticalScrollBarVisibility = ScrollBarVisibility.Never }, 0, 1);
+ column.Add(_footer, 0, 2);
+
+ var root = new Grid { IsClippedToBounds = true, Children = { column, _edge } };
+ root.Token(VisualElement.BackgroundColorProperty, ShellToken.Card);
+ Content = root;
+ WidthRequest = ExpandedWidth;
+ HorizontalOptions = LayoutOptions.Start;
+ UpdateEdge();
+ Loaded += (_, _) => Refresh();
+ }
+
+ public void Toggle() => IsCollapsed = !IsCollapsed;
+
+ private void OnCollapsedChanged()
+ {
+ Refresh();
+ ApplyWidth(animate: true);
+ IsCollapsedChanged?.Invoke(this, IsCollapsed);
+ }
+
+ private void OnSelectedValueChanged()
+ {
+ Refresh();
+ SelectedValueChanged?.Invoke(this, SelectedValue);
+ }
+
+ private void ApplyWidth(bool animate)
+ {
+ var target = IsCollapsed ? CollapsedWidth : ExpandedWidth;
+ this.AbortAnimation(WidthAnimation);
+ if (!animate || Handler is null || Width <= 0)
+ {
+ WidthRequest = target;
+ return;
+ }
+ new Animation(v => WidthRequest = v, Width, target)
+ .Commit(this, WidthAnimation, 16, 200, Easing.CubicOut);
+ }
+
+ private void UpdateEdge() =>
+ _edge.HorizontalOptions = Side == SidebarSide.Left ? LayoutOptions.End : LayoutOptions.Start;
+
+ private void UpdateSlots()
+ {
+ _header.Content = Header;
+ _header.IsVisible = Header != null;
+ _footer.Content = Footer;
+ _footer.IsVisible = Footer != null;
+ }
+
+ // Groups and items don't track the sidebar's state: the sidebar pushes it to them.
+ private void Refresh()
+ {
+ foreach (var group in this.FindDescendantsOfType())
+ group.Apply(IsCollapsed);
+ foreach (var item in this.FindDescendantsOfType())
+ item.Apply(isActive: !string.IsNullOrEmpty(item.Value) && item.Value == SelectedValue, collapsed: IsCollapsed);
+ }
+}
+
+// A labelled group of SidebarItems — the label is text-xs font-medium in the muted foreground.
+[ContentProperty(nameof(Items))]
+public partial class SidebarGroup : ContentView
+{
+ public static readonly BindableProperty LabelProperty =
+ BindableProperty.Create(nameof(Label), typeof(string), typeof(SidebarGroup), string.Empty,
+ propertyChanged: (b, o, n) => ((SidebarGroup)b).Update());
+
+ public string Label
+ {
+ get => (string)GetValue(LabelProperty);
+ set => SetValue(LabelProperty, value);
+ }
+
+ public IList Items => _items.Children;
+
+ private readonly Microsoft.Maui.Controls.Label _label;
+ private readonly VerticalStackLayout _items;
+ private bool _collapsed;
+
+ public SidebarGroup()
+ {
+ _label = new Microsoft.Maui.Controls.Label
+ {
+ FontSize = 12,
+ FontAttributes = FontAttributes.Bold,
+ HeightRequest = 28,
+ Padding = new Thickness(8, 0),
+ VerticalTextAlignment = TextAlignment.Center,
+ LineBreakMode = LineBreakMode.TailTruncation
+ };
+ _label.Token(Microsoft.Maui.Controls.Label.TextColorProperty, ShellToken.MutedForeground);
+ _items = new VerticalStackLayout { Spacing = 2 };
+ Content = new VerticalStackLayout { Spacing = 0, Children = { _label, _items } };
+ Update();
+ }
+
+ internal void Apply(bool collapsed)
+ {
+ _collapsed = collapsed;
+ Update();
+ }
+
+ private void Update()
+ {
+ _label.Text = Label ?? string.Empty;
+ _label.IsVisible = !_collapsed && !string.IsNullOrEmpty(Label);
+ }
+}
+
+// One row of a Sidebar — h-9 rounded-md px-2 text-sm: icon, text and an optional badge;
+// hover:bg-accent, and bg-accent font-medium when active. Collapsed, only the icon shows.
+public partial class SidebarItem : ContentView
+{
+ public static readonly BindableProperty TextProperty =
+ BindableProperty.Create(nameof(Text), typeof(string), typeof(SidebarItem), string.Empty,
+ propertyChanged: (b, o, n) => ((SidebarItem)b).Update());
+
+ // Identifies the item for Sidebar.SelectedValue.
+ public static readonly BindableProperty ValueProperty =
+ BindableProperty.Create(nameof(Value), typeof(string), typeof(SidebarItem), null);
+
+ public static readonly BindableProperty IconProperty =
+ BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(SidebarItem), IconName.None,
+ propertyChanged: (b, o, n) => ((SidebarItem)b).Update());
+
+ // Short text at the end of the row, e.g. a count.
+ public static readonly BindableProperty BadgeProperty =
+ BindableProperty.Create(nameof(Badge), typeof(string), typeof(SidebarItem), string.Empty,
+ propertyChanged: (b, o, n) => ((SidebarItem)b).Update());
+
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public string? Value
+ {
+ get => (string?)GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => (IconName)GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public string Badge
+ {
+ get => (string)GetValue(BadgeProperty);
+ set => SetValue(BadgeProperty, value);
+ }
+
+ public event EventHandler? Clicked;
+
+ private readonly Border _row;
+ private readonly Grid _grid;
+ private readonly Icon _icon;
+ private readonly Label _label;
+ private readonly Label _badge;
+ private bool _active;
+ private bool _collapsed;
+ private bool _hovered;
+
+ public SidebarItem()
+ {
+ _icon = new Icon { Size = 16, VerticalOptions = LayoutOptions.Center, HorizontalOptions = LayoutOptions.Center };
+ _label = new Label { FontSize = 14, VerticalTextAlignment = TextAlignment.Center, VerticalOptions = LayoutOptions.Center, LineBreakMode = LineBreakMode.TailTruncation };
+ _badge = new Label { FontSize = 12, VerticalTextAlignment = TextAlignment.Center, VerticalOptions = LayoutOptions.Center, IsVisible = false };
+ _badge.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+
+ _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(_badge, 2, 0);
+
+ _row = new Border
+ {
+ Content = _grid,
+ HeightRequest = 36,
+ Padding = new Thickness(8, 0),
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ BackgroundColor = Colors.Transparent
+ };
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => { _hovered = true; Update(); };
+ pointer.PointerExited += (_, _) => { _hovered = false; Update(); };
+ _row.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { ShellFocus.FocusPressed(this); Press(); };
+ _row.GestureRecognizers.Add(tap);
+ ShellFocus.MakeFocusable(this, Press);
+
+ Content = _row;
+ Update();
+ }
+
+ private void Press()
+ {
+ if (!IsEnabled) return;
+ if (!string.IsNullOrEmpty(Value) && this.FindParentOfType() is { } sidebar) sidebar.SelectedValue = Value;
+ Clicked?.Invoke(this, EventArgs.Empty);
+ }
+
+ internal void Apply(bool isActive, bool collapsed)
+ {
+ _active = isActive;
+ _collapsed = collapsed;
+ Update();
+ }
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == IsEnabledProperty.PropertyName)
+ Opacity = IsEnabled ? 1.0 : 0.5;
+ }
+
+ private void Update()
+ {
+ _label.Text = Text ?? string.Empty;
+ _label.FontAttributes = _active ? FontAttributes.Bold : FontAttributes.None;
+ _label.IsVisible = !_collapsed;
+ _icon.Name = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ _badge.Text = Badge ?? string.Empty;
+ _badge.IsVisible = !_collapsed && !string.IsNullOrEmpty(Badge);
+ // Collapsed, the icon column takes the row so the icon sits in the middle of the rail.
+ _grid.ColumnDefinitions[0].Width = _collapsed ? GridLength.Star : GridLength.Auto;
+ _grid.ColumnDefinitions[1].Width = _collapsed ? new GridLength(0) : GridLength.Star;
+ _grid.ColumnSpacing = _collapsed ? 0 : 8;
+ SemanticProperties.SetDescription(this, Text ?? string.Empty);
+
+ var foreground = _active ? ShellToken.AccentForeground : ShellToken.Foreground;
+ _label.Token(Label.TextColorProperty, foreground);
+ _icon.Token = foreground;
+
+ if (_active || (_hovered && IsEnabled))
+ _row.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent);
+ else
+ {
+ _row.ClearValue(VisualElement.BackgroundColorProperty);
+ _row.BackgroundColor = Colors.Transparent;
+ }
+ }
+}
+
+public enum SidebarSide { Left, Right }
diff --git a/examples/MAUI.Demo/MainPage.xaml b/examples/MAUI.Demo/MainPage.xaml
index 3aa413c..423366a 100644
--- a/examples/MAUI.Demo/MainPage.xaml
+++ b/examples/MAUI.Demo/MainPage.xaml
@@ -229,6 +229,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/MAUI.Demo/MainPage.xaml.cs b/examples/MAUI.Demo/MainPage.xaml.cs
index c00e870..9d374ae 100644
--- a/examples/MAUI.Demo/MainPage.xaml.cs
+++ b/examples/MAUI.Demo/MainPage.xaml.cs
@@ -105,6 +105,10 @@ private void OnTimeChanged(object? sender, EventArgs e)
if (sender is Components.UI.TimePicker picker)
Report($"Time: {picker.Time.Hours:00}:{picker.Time.Minutes:00}");
}
+ private void OnDateRangeChanged(object? sender, EventArgs e) =>
+ Report($"Date range: {DateRangeDemo.StartDate:d} to {(DateRangeDemo.EndDate.HasValue ? DateRangeDemo.EndDate.Value.ToString("d") : "…")}");
+ private void OnFilesChanged(object? sender, IReadOnlyList files) =>
+ Report($"Files: {(files.Count == 0 ? "none" : string.Join(", ", files.Select(f => f.FileName)))}");
private void OnComboboxChanged(object? sender, string value) => Report($"Combobox: {value}");
private void OnTogglePressed(object? sender, bool pressed) => Report($"Toggle: {(pressed ? "on" : "off")}");
@@ -138,6 +142,22 @@ private void OnTableRowTapped(object? sender, EventArgs e)
Report($"Table: {row.Children.OfType().FirstOrDefault()?.Text}");
}
+ private void OnNavbarChanged(object? sender, string? value) => Report($"Navbar: {value}");
+ private void OnSidebarChanged(object? sender, string? value)
+ {
+ SidebarPage.Text = value is null ? string.Empty : char.ToUpperInvariant(value[0]) + value[1..];
+ Report($"Sidebar: {value}");
+ }
+ private void OnSidebarToggle(object? sender, EventArgs e) => SidebarDemo.Toggle();
+ private void OnSidebarCollapsed(object? sender, bool collapsed)
+ {
+ SidebarBrand.IsVisible = !collapsed;
+ Report($"Sidebar: {(collapsed ? "collapsed" : "expanded")}");
+ }
+ private void OnSidebarLogOut(object? sender, EventArgs e) => Report("Sidebar: Log out");
+ private void OnResized(object? sender, EventArgs e) =>
+ Report($"Resizable: first panel is {ResizableFirst.Size / ResizableDemo.Panels.Sum(p => p.Size):P0} of the width");
+
private void OnDropdownItem(object? sender, EventArgs e) => Report($"Dropdown: {(sender as DropdownItem)?.Text}");
private void OnTabChanged(object? sender, (string Old, string New) e) => Report($"Tab: {e.New}");
diff --git a/scripts/sync-templates.py b/scripts/sync-templates.py
index eebda55..b9a8a16 100644
--- a/scripts/sync-templates.py
+++ b/scripts/sync-templates.py
@@ -84,12 +84,23 @@
["select", "multiple", "chips", "search"]),
"EmptyState": ("empty-state", "Empty State", "Placeholder for an empty list or screen", "DataDisplay",
["empty", "placeholder", "state"]),
+ "Resizable": ("resizable", "Resizable", "Panels that share space and resize by dragging the divider", "Layout",
+ ["resizable", "split", "panels", "layout"]),
+ "Navbar": ("navbar", "Navbar", "Top navigation bar that collapses its links into a menu", "Navigation",
+ ["navbar", "navigation", "header", "menu"]),
+ "Sidebar": ("sidebar", "Sidebar", "App sidebar with groups and items that collapses to an icon rail", "Navigation",
+ ["sidebar", "navigation", "menu", "layout"]),
+ "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",
+ ["file", "upload", "picker", "drop"]),
}
# Component-to-component dependencies the code scan can't infer.
EXTRA_DEPS = {
"HoverCard": ["hover-card-trigger", "hover-card-content"],
"DatePicker": ["calendar"],
+ "DateRangePicker": ["calendar"],
"TagInput": ["wrap-layout"],
"MultiSelect": ["wrap-layout"],
}
diff --git a/src/ShellUI.Native.Templates/ComponentRegistry.cs b/src/ShellUI.Native.Templates/ComponentRegistry.cs
index 84960e3..2d715c6 100644
--- a/src/ShellUI.Native.Templates/ComponentRegistry.cs
+++ b/src/ShellUI.Native.Templates/ComponentRegistry.cs
@@ -121,7 +121,12 @@ public static class ComponentRegistry
{ "aspect-ratio", (AspectRatioTemplate.Metadata, AspectRatioTemplate.Contents) },
{ "link-card", (LinkCardTemplate.Metadata, LinkCardTemplate.Contents) },
{ "tree-view", (TreeViewTemplate.Metadata, TreeViewTemplate.Contents) },
- { "multi-select", (MultiSelectTemplate.Metadata, MultiSelectTemplate.Contents) }
+ { "multi-select", (MultiSelectTemplate.Metadata, MultiSelectTemplate.Contents) },
+ { "resizable", (ResizableTemplate.Metadata, ResizableTemplate.Contents) },
+ { "navbar", (NavbarTemplate.Metadata, NavbarTemplate.Contents) },
+ { "sidebar", (SidebarTemplate.Metadata, SidebarTemplate.Contents) },
+ { "date-range-picker", (DateRangePickerTemplate.Metadata, DateRangePickerTemplate.Contents) },
+ { "file-upload", (FileUploadTemplate.Metadata, FileUploadTemplate.Contents) }
};
// Metadata-only view for callers that don't need the per-platform payload.
diff --git a/src/ShellUI.Native.Templates/Templates/CalendarTemplate.cs b/src/ShellUI.Native.Templates/Templates/CalendarTemplate.cs
index 516fe6c..00df76c 100644
--- a/src/ShellUI.Native.Templates/Templates/CalendarTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/CalendarTemplate.cs
@@ -24,7 +24,8 @@ namespace YourProjectNamespace.Components.UI;
// Month calendar — prev/next header, weekday row, 6x7 day grid of 32px cells. Selected day is
// bg-primary, today is bg-accent, days outside the month are muted. Month names, weekday names
-// and the first day of the week follow the current culture.
+// and the first day of the week follow the current culture. RangeStart and RangeEnd tint a span
+// of days (DateRangePicker uses them).
// Usage:
public partial class Calendar : ContentView
{
@@ -40,12 +41,33 @@ public partial class Calendar : ContentView
BindableProperty.Create(nameof(MaximumDate), typeof(DateTime?), typeof(Calendar), null,
propertyChanged: (b, o, n) => ((Calendar)b).Refresh());
+ // The ends of a highlighted span: both ends are bg-primary, the days between bg-accent.
+ public static readonly BindableProperty RangeStartProperty =
+ BindableProperty.Create(nameof(RangeStart), typeof(DateTime?), typeof(Calendar), null,
+ propertyChanged: (b, o, n) => ((Calendar)b).Refresh());
+
+ public static readonly BindableProperty RangeEndProperty =
+ BindableProperty.Create(nameof(RangeEnd), typeof(DateTime?), typeof(Calendar), null,
+ propertyChanged: (b, o, n) => ((Calendar)b).Refresh());
+
public DateTime? SelectedDate
{
get => (DateTime?)GetValue(SelectedDateProperty);
set => SetValue(SelectedDateProperty, value);
}
+ public DateTime? RangeStart
+ {
+ get => (DateTime?)GetValue(RangeStartProperty);
+ set => SetValue(RangeStartProperty, value);
+ }
+
+ public DateTime? RangeEnd
+ {
+ get => (DateTime?)GetValue(RangeEndProperty);
+ set => SetValue(RangeEndProperty, value);
+ }
+
public DateTime? MinimumDate
{
get => (DateTime?)GetValue(MinimumDateProperty);
@@ -73,6 +95,7 @@ public DateTime DisplayMonth
private readonly Label _title;
private readonly List<(Border Box, Label Text)> _cells = new();
private readonly DateTime[] _dates = new DateTime[42];
+ private readonly bool[] _square = new bool[42];
private int _hovered = -1;
public Calendar()
@@ -211,12 +234,13 @@ private void StyleCell(int index)
var date = _dates[index];
var inMonth = date.Month == _month.Month;
var selectable = IsSelectable(date);
- var selected = SelectedDate?.Date == date;
+ var selected = SelectedDate?.Date == date || RangeStart?.Date == date || RangeEnd?.Date == date;
+ var inRange = !selected && RangeStart is { } from && RangeEnd is { } to && date > from.Date && date < to.Date;
var today = date == DateTime.Today;
if (selected)
box.Token(VisualElement.BackgroundColorProperty, ShellToken.Primary);
- else if (today || (index == _hovered && selectable))
+ else if (inRange || today || (index == _hovered && selectable))
box.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent);
else
{
@@ -224,9 +248,16 @@ private void StyleCell(int index)
box.BackgroundColor = Colors.Transparent;
}
+ // Days inside a span lose their corners, so the span reads as one band.
+ if (_square[index] != inRange)
+ {
+ _square[index] = inRange;
+ box.StrokeShape = new RoundRectangle { CornerRadius = inRange ? 0 : ShellTheme.RadiusMd };
+ }
+
text.Token(Label.TextColorProperty,
- selected ? ShellToken.PrimaryForeground : inMonth ? ShellToken.Foreground : ShellToken.MutedForeground);
- box.Opacity = !selectable ? 0.3 : inMonth || selected ? 1 : 0.5;
+ selected ? ShellToken.PrimaryForeground : inMonth || inRange ? ShellToken.Foreground : ShellToken.MutedForeground);
+ box.Opacity = !selectable ? 0.3 : inMonth || selected || inRange ? 1 : 0.5;
}
}
",
@@ -245,7 +276,8 @@ namespace YourProjectNamespace.Components.UI;
// Month calendar — prev/next header, weekday row, 6x7 day grid of 32px cells. Selected day is
// bg-primary, today is bg-accent, days outside the month are muted. Month names, weekday names
-// and the first day of the week follow the current culture.
+// and the first day of the week follow the current culture. RangeStart and RangeEnd tint a span
+// of days (DateRangePicker uses them).
// Usage:
public class Calendar : Border
{
@@ -258,6 +290,13 @@ public class Calendar : Border
public static readonly StyledProperty MaximumDateProperty =
AvaloniaProperty.Register(nameof(MaximumDate));
+ // The ends of a highlighted span: both ends are bg-primary, the days between bg-accent.
+ public static readonly StyledProperty RangeStartProperty =
+ AvaloniaProperty.Register(nameof(RangeStart));
+
+ public static readonly StyledProperty RangeEndProperty =
+ AvaloniaProperty.Register(nameof(RangeEnd));
+
private const double Cell = 32;
private DateTime _month = new(DateTime.Today.Year, DateTime.Today.Month, 1);
@@ -271,6 +310,8 @@ static Calendar()
SelectedDateProperty.Changed.AddClassHandler((c, _) => c.OnSelectedDateChanged());
MinimumDateProperty.Changed.AddClassHandler((c, _) => c.Refresh());
MaximumDateProperty.Changed.AddClassHandler((c, _) => c.Refresh());
+ RangeStartProperty.Changed.AddClassHandler((c, _) => c.Refresh());
+ RangeEndProperty.Changed.AddClassHandler((c, _) => c.Refresh());
}
public DateTime? SelectedDate
@@ -279,6 +320,18 @@ public DateTime? SelectedDate
set => SetValue(SelectedDateProperty, value);
}
+ public DateTime? RangeStart
+ {
+ get => GetValue(RangeStartProperty);
+ set => SetValue(RangeStartProperty, value);
+ }
+
+ public DateTime? RangeEnd
+ {
+ get => GetValue(RangeEndProperty);
+ set => SetValue(RangeEndProperty, value);
+ }
+
public DateTime? MinimumDate
{
get => GetValue(MinimumDateProperty);
@@ -426,12 +479,13 @@ private void StyleCell(int index)
var date = _dates[index];
var inMonth = date.Month == _month.Month;
var selectable = IsSelectable(date);
- var selected = SelectedDate?.Date == date;
+ var selected = SelectedDate?.Date == date || RangeStart?.Date == date || RangeEnd?.Date == date;
+ var inRange = !selected && RangeStart is { } from && RangeEnd is { } to && date > from.Date && date < to.Date;
var today = date == DateTime.Today;
if (selected)
box.Token(BackgroundProperty, ShellToken.Primary);
- else if (today || (index == _hovered && selectable))
+ else if (inRange || today || (index == _hovered && selectable))
box.Token(BackgroundProperty, ShellToken.Accent);
else
{
@@ -439,9 +493,12 @@ private void StyleCell(int index)
box.Background = Brushes.Transparent;
}
+ // Days inside a span lose their corners, so the span reads as one band.
+ box.CornerRadius = new CornerRadius(inRange ? 0 : ShellTheme.RadiusMd);
+
text.Token(TextBlock.ForegroundProperty,
- selected ? ShellToken.PrimaryForeground : inMonth ? ShellToken.Foreground : ShellToken.MutedForeground);
- box.Opacity = !selectable ? 0.3 : inMonth || selected ? 1 : 0.5;
+ selected ? ShellToken.PrimaryForeground : inMonth || inRange ? ShellToken.Foreground : ShellToken.MutedForeground);
+ box.Opacity = !selectable ? 0.3 : inMonth || selected || inRange ? 1 : 0.5;
}
}
"
diff --git a/src/ShellUI.Native.Templates/Templates/DateRangePickerTemplate.cs b/src/ShellUI.Native.Templates/Templates/DateRangePickerTemplate.cs
new file mode 100644
index 0000000..beb720c
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/DateRangePickerTemplate.cs
@@ -0,0 +1,514 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class DateRangePickerTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "date-range-picker",
+ DisplayName = "Date Range Picker",
+ Description = "Picks a start and an end date from one calendar",
+ Category = ComponentCategory.Form,
+ FilePath = "DateRangePicker.cs",
+ Dependencies = new List { "shell", "icon", "calendar" },
+ Tags = new List { "date", "range", "picker", "calendar" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"using System.Globalization;
+using Microsoft.Maui.Controls.Shapes;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Date range picker — a DatePicker-style trigger (h-10 rounded-md border) that opens a Calendar.
+// The first pick is the start, the second the end (picking an earlier day starts over); the
+// panel closes when the range is complete. The × clears it.
+// Usage:
+public partial class DateRangePicker : ContentView, IShellPopup
+{
+ public static readonly BindableProperty StartDateProperty =
+ BindableProperty.Create(nameof(StartDate), typeof(DateTime?), typeof(DateRangePicker), null, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((DateRangePicker)b).OnRangeChanged());
+
+ public static readonly BindableProperty EndDateProperty =
+ BindableProperty.Create(nameof(EndDate), typeof(DateTime?), typeof(DateRangePicker), null, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((DateRangePicker)b).OnRangeChanged());
+
+ public static readonly BindableProperty PlaceholderProperty =
+ BindableProperty.Create(nameof(Placeholder), typeof(string), typeof(DateRangePicker), ""Pick a date range"",
+ propertyChanged: (b, o, n) => ((DateRangePicker)b).UpdateTrigger());
+
+ // .NET date format string for the trigger text.
+ public static readonly BindableProperty FormatProperty =
+ BindableProperty.Create(nameof(Format), typeof(string), typeof(DateRangePicker), ""MMM d, yyyy"",
+ propertyChanged: (b, o, n) => ((DateRangePicker)b).UpdateTrigger());
+
+ // Shows the × that clears the range.
+ public static readonly BindableProperty AllowClearProperty =
+ BindableProperty.Create(nameof(AllowClear), typeof(bool), typeof(DateRangePicker), true,
+ propertyChanged: (b, o, n) => ((DateRangePicker)b).UpdateTrigger());
+
+ public static readonly BindableProperty MinimumDateProperty =
+ BindableProperty.Create(nameof(MinimumDate), typeof(DateTime?), typeof(DateRangePicker), null,
+ propertyChanged: (b, o, n) => ((DateRangePicker)b)._calendar.MinimumDate = (DateTime?)n);
+
+ public static readonly BindableProperty MaximumDateProperty =
+ BindableProperty.Create(nameof(MaximumDate), typeof(DateTime?), typeof(DateRangePicker), null,
+ propertyChanged: (b, o, n) => ((DateRangePicker)b)._calendar.MaximumDate = (DateTime?)n);
+
+ public DateTime? StartDate
+ {
+ get => (DateTime?)GetValue(StartDateProperty);
+ set => SetValue(StartDateProperty, value);
+ }
+
+ public DateTime? EndDate
+ {
+ get => (DateTime?)GetValue(EndDateProperty);
+ set => SetValue(EndDateProperty, value);
+ }
+
+ public string Placeholder
+ {
+ get => (string)GetValue(PlaceholderProperty);
+ set => SetValue(PlaceholderProperty, value);
+ }
+
+ public string Format
+ {
+ get => (string)GetValue(FormatProperty);
+ set => SetValue(FormatProperty, value);
+ }
+
+ public bool AllowClear
+ {
+ get => (bool)GetValue(AllowClearProperty);
+ set => SetValue(AllowClearProperty, value);
+ }
+
+ public DateTime? MinimumDate
+ {
+ get => (DateTime?)GetValue(MinimumDateProperty);
+ set => SetValue(MinimumDateProperty, value);
+ }
+
+ public DateTime? MaximumDate
+ {
+ get => (DateTime?)GetValue(MaximumDateProperty);
+ set => SetValue(MaximumDateProperty, value);
+ }
+
+ public bool IsOpen { get; private set; }
+
+ // Raised when StartDate or EndDate changes.
+ public event EventHandler? RangeChanged;
+
+ private readonly Border _trigger;
+ private readonly Label _text;
+ private readonly Grid _clear;
+ private readonly Calendar _calendar;
+ private readonly Border _panel;
+ private ShellPopupHandle? _handle;
+ private bool _clearTapped;
+
+ public DateRangePicker()
+ {
+ _text = new Label { FontSize = 14, VerticalOptions = LayoutOptions.Center, VerticalTextAlignment = TextAlignment.Center, LineBreakMode = LineBreakMode.TailTruncation };
+
+ var close = new Icon { Name = IconName.X, Size = 14, Token = ShellToken.MutedForeground, HorizontalOptions = LayoutOptions.Center, VerticalOptions = LayoutOptions.Center };
+ // 14px icon in a 24px tap target.
+ _clear = new Grid { WidthRequest = 24, HeightRequest = 24, BackgroundColor = Colors.Transparent, VerticalOptions = LayoutOptions.Center, IsVisible = false, Children = { close } };
+ SemanticProperties.SetDescription(_clear, ""Clear dates"");
+ var clearPointer = new PointerGestureRecognizer();
+ clearPointer.PointerEntered += (_, _) => close.Token = ShellToken.Foreground;
+ clearPointer.PointerExited += (_, _) => close.Token = ShellToken.MutedForeground;
+ _clear.GestureRecognizers.Add(clearPointer);
+ var clearTap = new TapGestureRecognizer();
+ clearTap.Tapped += (_, _) =>
+ {
+ if (!IsEnabled) return;
+ _clearTapped = true;
+ // Cleared on the next turn in case the tap never reaches the trigger.
+ Dispatcher.Dispatch(() => _clearTapped = false);
+ Clear();
+ };
+ _clear.GestureRecognizers.Add(clearTap);
+
+ var row = new Grid
+ {
+ ColumnSpacing = 8,
+ ColumnDefinitions =
+ {
+ new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto)
+ }
+ };
+ row.Add(new Icon { Name = IconName.Calendar, Size = 16, Token = ShellToken.MutedForeground, VerticalOptions = LayoutOptions.Center }, 0, 0);
+ row.Add(_text, 1, 0);
+ row.Add(_clear, 2, 0);
+
+ _trigger = new Border
+ {
+ Content = row,
+ HeightRequest = 40,
+ MinimumWidthRequest = 260,
+ Padding = new Thickness(12, 0, 8, 0),
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ BackgroundColor = Colors.Transparent
+ };
+ _trigger.Token(Border.StrokeProperty, ShellToken.Input);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) =>
+ {
+ // A tap on the × can reach the trigger too; it must not toggle the panel.
+ if (_clearTapped) { _clearTapped = false; return; }
+ if (!IsEnabled) return;
+ ShellFocus.FocusPressed(this);
+ SetOpen(!IsOpen);
+ };
+ _trigger.GestureRecognizers.Add(tap);
+ ShellFocus.MakeFocusable(this, () => { if (IsEnabled) SetOpen(!IsOpen); });
+
+ _calendar = new Calendar();
+ _calendar.DateSelected += (_, date) => Pick(date);
+
+ _panel = new Border
+ {
+ Content = _calendar,
+ Padding = new Thickness(12),
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ Shadow = ShellPopups.PanelShadow()
+ };
+ _panel.Token(VisualElement.BackgroundColorProperty, ShellToken.Popover);
+ _panel.Token(Border.StrokeProperty, ShellToken.Border);
+
+ Content = _trigger;
+ HorizontalOptions = LayoutOptions.Start;
+ UpdateTrigger();
+ }
+
+ public void SetOpen(bool open)
+ {
+ if (IsOpen == open) return;
+ IsOpen = open;
+ _trigger.Token(Border.StrokeProperty, open ? ShellToken.Ring : ShellToken.Input);
+ if (open)
+ {
+ UpdateCalendar();
+ _calendar.DisplayMonth = StartDate ?? DateTime.Today;
+ ShellPopups.Opened(this);
+ _handle = ShellPortal.ShowPopup(_trigger, _panel, new ShellPopupOptions { Owner = this, OnDismiss = Close });
+ }
+ else
+ {
+ ShellPopups.Closed(this);
+ var handle = _handle;
+ _handle = null;
+ if (handle != null) _ = handle.CloseAsync();
+ }
+ }
+
+ public void Close() => SetOpen(false);
+
+ public void Clear()
+ {
+ StartDate = null;
+ EndDate = null;
+ }
+
+ // The first pick (or a pick with a complete range, or one before the start) begins a new
+ // range; the next pick ends it.
+ private void Pick(DateTime date)
+ {
+ if (StartDate is not { } start || EndDate.HasValue || date < start.Date)
+ {
+ EndDate = null;
+ StartDate = date;
+ return;
+ }
+ EndDate = date;
+ SetOpen(false);
+ }
+
+ private void OnRangeChanged()
+ {
+ UpdateTrigger();
+ UpdateCalendar();
+ RangeChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void UpdateCalendar()
+ {
+ // The calendar marks the range itself; its own single selection stays empty.
+ _calendar.SelectedDate = null;
+ _calendar.RangeStart = StartDate;
+ _calendar.RangeEnd = EndDate;
+ }
+
+ private void UpdateTrigger()
+ {
+ var culture = CultureInfo.CurrentCulture;
+ var hasValue = StartDate.HasValue || EndDate.HasValue;
+ _text.Text = StartDate is { } start
+ ? EndDate is { } end ? $""{start.ToString(Format, culture)} – {end.ToString(Format, culture)}"" : $""{start.ToString(Format, culture)} – …""
+ : Placeholder;
+ _text.Token(Label.TextColorProperty, hasValue ? ShellToken.Foreground : ShellToken.MutedForeground);
+ _clear.IsVisible = AllowClear && hasValue;
+ }
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == IsEnabledProperty.PropertyName)
+ Opacity = IsEnabled ? 1.0 : 0.5;
+ }
+}
+",
+ [NativePlatform.Avalonia] = @"using System;
+using System.Globalization;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Date range picker — a DatePicker-style trigger (h-10 rounded-md border) that opens a Calendar.
+// The first pick is the start, the second the end (picking an earlier day starts over); the
+// panel closes when the range is complete. The × clears it.
+// Usage:
+public class DateRangePicker : Border
+{
+ public static readonly StyledProperty StartDateProperty =
+ AvaloniaProperty.Register(nameof(StartDate), defaultBindingMode: BindingMode.TwoWay);
+
+ public static readonly StyledProperty EndDateProperty =
+ AvaloniaProperty.Register(nameof(EndDate), defaultBindingMode: BindingMode.TwoWay);
+
+ public static readonly StyledProperty PlaceholderProperty =
+ AvaloniaProperty.Register(nameof(Placeholder), ""Pick a date range"");
+
+ // .NET date format string for the trigger text.
+ public static readonly StyledProperty FormatProperty =
+ AvaloniaProperty.Register(nameof(Format), ""MMM d, yyyy"");
+
+ // Shows the × that clears the range.
+ public static readonly StyledProperty AllowClearProperty =
+ AvaloniaProperty.Register(nameof(AllowClear), true);
+
+ public static readonly StyledProperty MinimumDateProperty =
+ AvaloniaProperty.Register(nameof(MinimumDate));
+
+ public static readonly StyledProperty MaximumDateProperty =
+ AvaloniaProperty.Register(nameof(MaximumDate));
+
+ private readonly Border _trigger;
+ private readonly TextBlock _text;
+ private readonly Border _clear;
+ private readonly Calendar _calendar;
+ private readonly ShellAnchoredPopup _popup;
+
+ static DateRangePicker()
+ {
+ StartDateProperty.Changed.AddClassHandler((p, _) => p.OnRangeChanged());
+ EndDateProperty.Changed.AddClassHandler((p, _) => p.OnRangeChanged());
+ PlaceholderProperty.Changed.AddClassHandler((p, _) => p.UpdateTrigger());
+ FormatProperty.Changed.AddClassHandler((p, _) => p.UpdateTrigger());
+ AllowClearProperty.Changed.AddClassHandler((p, _) => p.UpdateTrigger());
+ MinimumDateProperty.Changed.AddClassHandler((p, e) => p._calendar.MinimumDate = (DateTime?)e.NewValue);
+ MaximumDateProperty.Changed.AddClassHandler((p, e) => p._calendar.MaximumDate = (DateTime?)e.NewValue);
+ IsEnabledProperty.Changed.AddClassHandler((p, _) => p.Opacity = p.IsEnabled ? 1.0 : 0.5);
+ }
+
+ public DateTime? StartDate
+ {
+ get => GetValue(StartDateProperty);
+ set => SetValue(StartDateProperty, value);
+ }
+
+ public DateTime? EndDate
+ {
+ get => GetValue(EndDateProperty);
+ set => SetValue(EndDateProperty, value);
+ }
+
+ public string? Placeholder
+ {
+ get => GetValue(PlaceholderProperty);
+ set => SetValue(PlaceholderProperty, value);
+ }
+
+ public string Format
+ {
+ get => GetValue(FormatProperty);
+ set => SetValue(FormatProperty, value);
+ }
+
+ public bool AllowClear
+ {
+ get => GetValue(AllowClearProperty);
+ set => SetValue(AllowClearProperty, value);
+ }
+
+ public DateTime? MinimumDate
+ {
+ get => GetValue(MinimumDateProperty);
+ set => SetValue(MinimumDateProperty, value);
+ }
+
+ public DateTime? MaximumDate
+ {
+ get => GetValue(MaximumDateProperty);
+ set => SetValue(MaximumDateProperty, value);
+ }
+
+ public bool IsOpen => _popup.IsOpen;
+
+ // Raised when StartDate or EndDate changes.
+ public event EventHandler? RangeChanged;
+
+ public DateRangePicker()
+ {
+ _text = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis };
+ Grid.SetColumn(_text, 1);
+
+ var close = new Icon { Kind = IconName.X, Size = 14, Token = ShellToken.MutedForeground, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ // 14px icon in a 24px click target. Its clicks stop here, so they don't toggle the panel.
+ _clear = new Border { Width = 24, Height = 24, Background = Brushes.Transparent, Child = close, VerticalAlignment = VerticalAlignment.Center, IsVisible = false };
+ AutomationProperties.SetName(_clear, ""Clear dates"");
+ _clear.PointerEntered += (_, _) => close.Token = ShellToken.Foreground;
+ _clear.PointerExited += (_, _) => close.Token = ShellToken.MutedForeground;
+ _clear.PointerPressed += (_, e) => e.Handled = true;
+ _clear.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ if (IsEffectivelyEnabled) Clear();
+ e.Handled = true;
+ };
+ Grid.SetColumn(_clear, 2);
+
+ _trigger = new Border
+ {
+ Child = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions(""Auto,*,Auto""),
+ ColumnSpacing = 8,
+ Children = { new Icon { Kind = IconName.Calendar, Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Center }, _text, _clear }
+ },
+ Height = 40,
+ MinWidth = 260,
+ Padding = new Thickness(12, 0, 8, 0),
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ Background = Brushes.Transparent,
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+ _trigger.Token(BorderBrushProperty, ShellToken.Input);
+ _trigger.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return;
+ SetOpen(!IsOpen);
+ e.Handled = true;
+ };
+
+ _calendar = new Calendar();
+ _calendar.DateSelected += (_, date) => Pick(date);
+ var panel = new Border
+ {
+ Child = _calendar,
+ Padding = new Thickness(12),
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ BoxShadow = ShellPopups.PanelShadow()
+ };
+ panel.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border);
+
+ _popup = new ShellAnchoredPopup(_trigger);
+ _popup.Popup.Child = panel;
+ _popup.Closed += (_, _) => _trigger.Token(BorderBrushProperty, ShellToken.Input);
+
+ Child = new Panel { Children = { _trigger, _popup.Popup } };
+ HorizontalAlignment = HorizontalAlignment.Left;
+ ShellFocus.Ring(this, _trigger);
+ UpdateTrigger();
+ }
+
+ public void SetOpen(bool open)
+ {
+ if (open == _popup.IsOpen) return;
+ if (!open)
+ {
+ _popup.Close();
+ return;
+ }
+ UpdateCalendar();
+ _calendar.DisplayMonth = StartDate ?? DateTime.Today;
+ _trigger.Token(BorderBrushProperty, ShellToken.Ring);
+ _popup.Open();
+ }
+
+ public void Close() => SetOpen(false);
+
+ public void Clear()
+ {
+ StartDate = null;
+ EndDate = null;
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if (IsOpen || !ReferenceEquals(e.Source, this) || e.Key is not (Key.Enter or Key.Space or Key.Down)) return;
+ SetOpen(true);
+ e.Handled = true;
+ }
+
+ // The first pick (or a pick with a complete range, or one before the start) begins a new
+ // range; the next pick ends it.
+ private void Pick(DateTime date)
+ {
+ if (StartDate is not { } start || EndDate.HasValue || date < start.Date)
+ {
+ EndDate = null;
+ StartDate = date;
+ return;
+ }
+ EndDate = date;
+ SetOpen(false);
+ }
+
+ private void OnRangeChanged()
+ {
+ UpdateTrigger();
+ UpdateCalendar();
+ RangeChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void UpdateCalendar()
+ {
+ // The calendar marks the range itself; its own single selection stays empty.
+ _calendar.SelectedDate = null;
+ _calendar.RangeStart = StartDate;
+ _calendar.RangeEnd = EndDate;
+ }
+
+ private void UpdateTrigger()
+ {
+ var culture = CultureInfo.CurrentCulture;
+ var hasValue = StartDate.HasValue || EndDate.HasValue;
+ _text.Text = StartDate is { } start
+ ? EndDate is { } end ? $""{start.ToString(Format, culture)} – {end.ToString(Format, culture)}"" : $""{start.ToString(Format, culture)} – …""
+ : Placeholder ?? string.Empty;
+ _text.Token(TextBlock.ForegroundProperty, hasValue ? ShellToken.Foreground : ShellToken.MutedForeground);
+ _clear.IsVisible = AllowClear && hasValue;
+ }
+}
+"
+ };
+}
diff --git a/src/ShellUI.Native.Templates/Templates/FileUploadTemplate.cs b/src/ShellUI.Native.Templates/Templates/FileUploadTemplate.cs
new file mode 100644
index 0000000..a13de3f
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/FileUploadTemplate.cs
@@ -0,0 +1,624 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class FileUploadTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "file-upload",
+ DisplayName = "File Upload",
+ Description = "Drop zone that opens the file picker and lists the picked files",
+ Category = ComponentCategory.Form,
+ FilePath = "FileUpload.cs",
+ Dependencies = new List { "shell", "icon" },
+ Tags = new List { "file", "upload", "picker", "drop" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"using Microsoft.Maui.Controls.Shapes;
+
+namespace YourProjectNamespace.Components.UI;
+
+// File picker — a dashed drop zone (rounded-lg border-2 border-dashed p-8) with an upload icon
+// and a prompt; tapping it opens the platform file picker. On Windows, files can also be dropped
+// on it. The picked files are listed underneath with a × to remove each.
+//
+// Read a file with FileResult.OpenReadAsync().
+public partial class FileUpload : ContentView
+{
+ public static readonly BindableProperty TextProperty =
+ BindableProperty.Create(nameof(Text), typeof(string), typeof(FileUpload), ""Drag and drop or click here to upload files"",
+ propertyChanged: (b, o, n) => ((FileUpload)b).Update());
+
+ // More than one file can be picked; new picks are added to the list.
+ public static readonly BindableProperty MultipleProperty =
+ BindableProperty.Create(nameof(Multiple), typeof(bool), typeof(FileUpload), false,
+ propertyChanged: (b, o, n) => ((FileUpload)b).Update());
+
+ // The most files the list holds when Multiple is on. 0 means no limit.
+ public static readonly BindableProperty MaxFilesProperty =
+ BindableProperty.Create(nameof(MaxFiles), typeof(int), typeof(FileUpload), 0,
+ propertyChanged: (b, o, n) => ((FileUpload)b).Update());
+
+ // Which files the picker offers, e.g. FilePickerFileType.Images. Null offers every file.
+ public static readonly BindableProperty FileTypesProperty =
+ BindableProperty.Create(nameof(FileTypes), typeof(FilePickerFileType), typeof(FileUpload), null);
+
+ // The list of picked files under the drop zone.
+ public static readonly BindableProperty ShowFilesProperty =
+ BindableProperty.Create(nameof(ShowFiles), typeof(bool), typeof(FileUpload), true,
+ propertyChanged: (b, o, n) => ((FileUpload)b).Rebuild());
+
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public bool Multiple
+ {
+ get => (bool)GetValue(MultipleProperty);
+ set => SetValue(MultipleProperty, value);
+ }
+
+ public int MaxFiles
+ {
+ get => (int)GetValue(MaxFilesProperty);
+ set => SetValue(MaxFilesProperty, value);
+ }
+
+ public FilePickerFileType? FileTypes
+ {
+ get => (FilePickerFileType?)GetValue(FileTypesProperty);
+ set => SetValue(FileTypesProperty, value);
+ }
+
+ public bool ShowFiles
+ {
+ get => (bool)GetValue(ShowFilesProperty);
+ set => SetValue(ShowFilesProperty, value);
+ }
+
+ public IReadOnlyList Files => _files;
+
+ public event EventHandler>? FilesChanged;
+
+ private readonly List _files = new();
+ private readonly Border _zone;
+ private readonly Label _text;
+ private readonly Label _hint;
+ private readonly VerticalStackLayout _list;
+ private bool _hovered;
+ private bool _dragOver;
+ private bool _picking;
+
+ public FileUpload()
+ {
+ _text = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold, HorizontalTextAlignment = TextAlignment.Center };
+ _text.Token(Label.TextColorProperty, ShellToken.Foreground);
+ _hint = new Label { FontSize = 12, HorizontalTextAlignment = TextAlignment.Center, IsVisible = false };
+ _hint.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+
+ _zone = new Border
+ {
+ Content = new VerticalStackLayout
+ {
+ Spacing = 4,
+ Children =
+ {
+ new Icon { Name = IconName.Upload, Size = 32, Token = ShellToken.MutedForeground, HorizontalOptions = LayoutOptions.Center, Margin = new Thickness(0, 0, 0, 8) },
+ _text,
+ _hint
+ }
+ },
+ Padding = new Thickness(32),
+ StrokeThickness = 2,
+ StrokeDashArray = new DoubleCollection { 3, 3 },
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusLg },
+ BackgroundColor = Colors.Transparent
+ };
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => { _hovered = true; Paint(); };
+ pointer.PointerExited += (_, _) => { _hovered = false; Paint(); };
+ _zone.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { ShellFocus.FocusPressed(this); _ = PickAsync(); };
+ _zone.GestureRecognizers.Add(tap);
+ ShellFocus.MakeFocusable(this, () => _ = PickAsync());
+
+ var drop = new DropGestureRecognizer();
+ drop.DragOver += (_, e) =>
+ {
+ if (!IsEnabled) return;
+ e.AcceptedOperation = DataPackageOperation.Copy;
+ _dragOver = true;
+ Paint();
+ };
+ drop.DragLeave += (_, _) => { _dragOver = false; Paint(); };
+ drop.Drop += OnDrop;
+ _zone.GestureRecognizers.Add(drop);
+
+ _list = new VerticalStackLayout { Spacing = 4, Margin = new Thickness(0, 8, 0, 0), IsVisible = false };
+ Content = new VerticalStackLayout { Spacing = 0, Children = { _zone, _list } };
+ Update();
+ }
+
+ // Opens the platform file picker and adds what was picked.
+ public async Task PickAsync()
+ {
+ if (!IsEnabled || _picking) return;
+ _picking = true;
+ try
+ {
+ var options = new PickOptions { FileTypes = FileTypes };
+ if (Multiple)
+ {
+ var picked = await FilePicker.Default.PickMultipleAsync(options);
+ if (picked != null) Add(picked.Where(f => f != null)!);
+ }
+ else
+ {
+ var picked = await FilePicker.Default.PickAsync(options);
+ if (picked != null) Add(new[] { picked });
+ }
+ }
+ catch (Exception)
+ {
+ // The picker was cancelled or isn't available here.
+ }
+ finally
+ {
+ _picking = false;
+ }
+ }
+
+ // Adds files as a pick or a drop would: one file replaces the current one unless Multiple
+ // is on, and the list never grows past MaxFiles.
+ public void Add(IEnumerable files)
+ {
+ var changed = false;
+ foreach (var file in files)
+ {
+ if (!Multiple)
+ {
+ _files.Clear();
+ _files.Add(file);
+ changed = true;
+ break;
+ }
+ if (MaxFiles > 0 && _files.Count >= MaxFiles) break;
+ if (_files.Any(f => f.FullPath == file.FullPath)) continue;
+ _files.Add(file);
+ changed = true;
+ }
+ if (changed) OnFilesChanged();
+ }
+
+ public void Remove(FileResult file)
+ {
+ if (_files.Remove(file)) OnFilesChanged();
+ }
+
+ public void Clear()
+ {
+ if (_files.Count == 0) return;
+ _files.Clear();
+ OnFilesChanged();
+ }
+
+ private void OnFilesChanged()
+ {
+ Rebuild();
+ FilesChanged?.Invoke(this, _files.ToList());
+ }
+
+ private async void OnDrop(object? sender, DropEventArgs e)
+ {
+ _dragOver = false;
+ Paint();
+ if (!IsEnabled) return;
+#if WINDOWS
+ try
+ {
+ if (e.PlatformArgs?.DragEventArgs.DataView is not { } data ||
+ !data.Contains(Windows.ApplicationModel.DataTransfer.StandardDataFormats.StorageItems)) return;
+ var items = await data.GetStorageItemsAsync();
+ Add(items.OfType().Select(f => new FileResult(f.Path)));
+ }
+ catch (Exception)
+ {
+ // The dropped data couldn't be read as files.
+ }
+#else
+ await Task.CompletedTask;
+#endif
+ }
+
+ private void Rebuild()
+ {
+ _list.Children.Clear();
+ _list.IsVisible = ShowFiles && _files.Count > 0;
+ if (!ShowFiles) return;
+ foreach (var file in _files)
+ _list.Children.Add(CreateRow(file));
+ }
+
+ // rounded-md border px-3 h-10: file icon, name, × to remove.
+ private View CreateRow(FileResult file)
+ {
+ var name = new Label { Text = file.FileName, FontSize = 14, VerticalTextAlignment = TextAlignment.Center, VerticalOptions = LayoutOptions.Center, LineBreakMode = LineBreakMode.MiddleTruncation };
+ name.Token(Label.TextColorProperty, ShellToken.Foreground);
+ var close = new Icon { Name = IconName.X, Size = 14, Token = ShellToken.MutedForeground, HorizontalOptions = LayoutOptions.Center, VerticalOptions = LayoutOptions.Center };
+ // 14px icon in a 24px tap target.
+ var remove = new Grid { WidthRequest = 24, HeightRequest = 24, BackgroundColor = Colors.Transparent, VerticalOptions = LayoutOptions.Center, Children = { close } };
+ SemanticProperties.SetDescription(remove, $""Remove {file.FileName}"");
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => close.Token = ShellToken.Destructive;
+ pointer.PointerExited += (_, _) => close.Token = ShellToken.MutedForeground;
+ remove.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { if (IsEnabled) Remove(file); };
+ remove.GestureRecognizers.Add(tap);
+
+ var grid = new Grid
+ {
+ ColumnSpacing = 8,
+ ColumnDefinitions =
+ {
+ new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto)
+ }
+ };
+ grid.Add(new Icon { Name = IconName.File, Size = 16, Token = ShellToken.MutedForeground, VerticalOptions = LayoutOptions.Center }, 0, 0);
+ grid.Add(name, 1, 0);
+ grid.Add(remove, 2, 0);
+
+ var row = new Border
+ {
+ Content = grid,
+ HeightRequest = 40,
+ Padding = new Thickness(12, 0, 8, 0),
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }
+ };
+ row.Token(Border.StrokeProperty, ShellToken.Border);
+ return row;
+ }
+
+ private void Update()
+ {
+ _text.Text = Text ?? string.Empty;
+ _hint.Text = MaxFiles > 1 ? $""Select up to {MaxFiles} files"" : string.Empty;
+ _hint.IsVisible = Multiple && MaxFiles > 1;
+ SemanticProperties.SetDescription(this, Text ?? string.Empty);
+ Paint();
+ }
+
+ private void Paint()
+ {
+ var active = _dragOver || (_hovered && IsEnabled);
+ _zone.Token(Border.StrokeProperty, _dragOver ? ShellToken.Primary : active ? ShellToken.Ring : ShellToken.Border);
+ if (active) _zone.Token(VisualElement.BackgroundColorProperty, ShellToken.Muted);
+ else
+ {
+ _zone.ClearValue(VisualElement.BackgroundColorProperty);
+ _zone.BackgroundColor = Colors.Transparent;
+ }
+ }
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == IsEnabledProperty.PropertyName && _zone != null)
+ {
+ Opacity = IsEnabled ? 1.0 : 0.5;
+ Paint();
+ }
+ }
+}
+",
+ [NativePlatform.Avalonia] = @"using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Controls.Shapes;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Platform.Storage;
+
+namespace YourProjectNamespace.Components.UI;
+
+// File picker — a dashed drop zone (rounded-lg border-2 border-dashed p-8) with an upload icon
+// and a prompt; clicking it (or Enter / Space) opens the file picker, and files can be dropped on
+// it. The picked files are listed underneath with a × to remove each.
+//
+// Read a file with IStorageFile.OpenReadAsync().
+public class FileUpload : Border, IShellFocusable
+{
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text), ""Drag and drop or click here to upload files"");
+
+ // More than one file can be picked; new picks are added to the list.
+ public static readonly StyledProperty MultipleProperty =
+ AvaloniaProperty.Register(nameof(Multiple));
+
+ // The most files the list holds when Multiple is on. 0 means no limit.
+ public static readonly StyledProperty MaxFilesProperty =
+ AvaloniaProperty.Register(nameof(MaxFiles));
+
+ // Which files the picker offers, e.g. FilePickerFileTypes.ImageAll. Null offers every file.
+ public static readonly StyledProperty?> FileTypesProperty =
+ AvaloniaProperty.Register?>(nameof(FileTypes));
+
+ // The list of picked files under the drop zone.
+ public static readonly StyledProperty ShowFilesProperty =
+ AvaloniaProperty.Register(nameof(ShowFiles), true);
+
+ private readonly List _files = new();
+ private readonly Panel _zone;
+ private readonly Rectangle _outline;
+ private readonly TextBlock _text;
+ private readonly TextBlock _hint;
+ private readonly StackPanel _list = new() { Spacing = 4, Margin = new Thickness(0, 8, 0, 0), IsVisible = false };
+ private bool _dragOver;
+ private bool _picking;
+
+ static FileUpload()
+ {
+ TextProperty.Changed.AddClassHandler((f, _) => f.Update());
+ MultipleProperty.Changed.AddClassHandler((f, _) => f.Update());
+ MaxFilesProperty.Changed.AddClassHandler((f, _) => f.Update());
+ ShowFilesProperty.Changed.AddClassHandler((f, _) => f.Rebuild());
+ IsEnabledProperty.Changed.AddClassHandler((f, _) => f.Paint());
+ }
+
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public bool Multiple
+ {
+ get => GetValue(MultipleProperty);
+ set => SetValue(MultipleProperty, value);
+ }
+
+ public int MaxFiles
+ {
+ get => GetValue(MaxFilesProperty);
+ set => SetValue(MaxFilesProperty, value);
+ }
+
+ public IReadOnlyList? FileTypes
+ {
+ get => GetValue(FileTypesProperty);
+ set => SetValue(FileTypesProperty, value);
+ }
+
+ public bool ShowFiles
+ {
+ get => GetValue(ShowFilesProperty);
+ set => SetValue(ShowFilesProperty, value);
+ }
+
+ public IReadOnlyList Files => _files;
+
+ public event EventHandler>? FilesChanged;
+
+ public FileUpload()
+ {
+ _text = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, TextAlignment = TextAlignment.Center, TextWrapping = TextWrapping.Wrap };
+ _text.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+ _hint = new TextBlock { FontSize = 12, TextAlignment = TextAlignment.Center, IsVisible = false };
+ _hint.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+
+ // A Border has no dashed stroke, so the outline is a Rectangle behind the content.
+ _outline = new Rectangle { StrokeThickness = 2, StrokeDashArray = new Avalonia.Collections.AvaloniaList { 3, 3 }, RadiusX = ShellTheme.RadiusLg, RadiusY = ShellTheme.RadiusLg };
+ _zone = new Panel
+ {
+ // Transparent, not null, so the whole zone takes clicks and drops.
+ Background = Brushes.Transparent,
+ Cursor = new Cursor(StandardCursorType.Hand),
+ Children =
+ {
+ _outline,
+ new StackPanel
+ {
+ Spacing = 4,
+ Margin = new Thickness(32),
+ Children =
+ {
+ new Icon { Kind = IconName.Upload, Size = 32, Token = ShellToken.MutedForeground, HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, 0, 0, 8) },
+ _text,
+ _hint
+ }
+ }
+ }
+ };
+ _zone.PointerEntered += (_, _) => Paint();
+ _zone.PointerExited += (_, _) => Paint();
+ _zone.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ _ = PickAsync();
+ e.Handled = true;
+ };
+
+ DragDrop.SetAllowDrop(_zone, true);
+ _zone.AddHandler(DragDrop.DragEnterEvent, (_, e) => SetDragOver(e, true));
+ _zone.AddHandler(DragDrop.DragOverEvent, (_, e) => SetDragOver(e, true));
+ _zone.AddHandler(DragDrop.DragLeaveEvent, (_, e) => SetDragOver(e, false));
+ _zone.AddHandler(DragDrop.DropEvent, OnDrop);
+
+ // The focus ring is a shadow, and a border with no background paints none.
+ var ring = new Border { Child = _zone, CornerRadius = new CornerRadius(ShellTheme.RadiusLg), Background = Brushes.Transparent };
+ Child = new StackPanel { Children = { ring, _list } };
+ ShellFocus.Ring(this, ring);
+ Update();
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if (!ReferenceEquals(e.Source, this) || e.Key is not (Key.Enter or Key.Space)) return;
+ _ = PickAsync();
+ e.Handled = true;
+ }
+
+ // Opens the file picker and adds what was picked.
+ public async Task PickAsync()
+ {
+ if (!IsEffectivelyEnabled || _picking || TopLevel.GetTopLevel(this) is not { } top) return;
+ _picking = true;
+ try
+ {
+ var picked = await top.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ AllowMultiple = Multiple,
+ FileTypeFilter = FileTypes
+ });
+ Add(picked);
+ }
+ catch (Exception)
+ {
+ // The picker isn't available here.
+ }
+ finally
+ {
+ _picking = false;
+ }
+ }
+
+ // Adds files as a pick or a drop would: one file replaces the current one unless Multiple
+ // is on, and the list never grows past MaxFiles.
+ public void Add(IEnumerable files)
+ {
+ var changed = false;
+ foreach (var file in files)
+ {
+ if (!Multiple)
+ {
+ _files.Clear();
+ _files.Add(file);
+ changed = true;
+ break;
+ }
+ if (MaxFiles > 0 && _files.Count >= MaxFiles) break;
+ if (_files.Any(f => f.Path == file.Path)) continue;
+ _files.Add(file);
+ changed = true;
+ }
+ if (changed) OnFilesChanged();
+ }
+
+ public void Remove(IStorageFile file)
+ {
+ if (_files.Remove(file)) OnFilesChanged();
+ }
+
+ public void Clear()
+ {
+ if (_files.Count == 0) return;
+ _files.Clear();
+ OnFilesChanged();
+ }
+
+ private void OnFilesChanged()
+ {
+ Rebuild();
+ FilesChanged?.Invoke(this, _files.ToList());
+ }
+
+ private void SetDragOver(DragEventArgs e, bool over)
+ {
+ e.DragEffects = over && IsEffectivelyEnabled ? DragDropEffects.Copy : DragDropEffects.None;
+ _dragOver = over && IsEffectivelyEnabled;
+ Paint();
+ }
+
+ private void OnDrop(object? sender, DragEventArgs e)
+ {
+ _dragOver = false;
+ Paint();
+ if (!IsEffectivelyEnabled) return;
+ if (e.DataTransfer.TryGetFiles() is { } items) Add(items.OfType());
+ e.Handled = true;
+ }
+
+ private void Rebuild()
+ {
+ _list.Children.Clear();
+ _list.IsVisible = ShowFiles && _files.Count > 0;
+ if (!ShowFiles) return;
+ foreach (var file in _files)
+ _list.Children.Add(CreateRow(file));
+ }
+
+ // rounded-md border px-3 h-10: file icon, name, × to remove.
+ private Control CreateRow(IStorageFile file)
+ {
+ var name = new TextBlock { Text = file.Name, FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis };
+ name.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+ Grid.SetColumn(name, 1);
+ var close = new Icon { Kind = IconName.X, Size = 14, Token = ShellToken.MutedForeground, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ // 14px icon in a 24px click target.
+ var remove = new Border { Width = 24, Height = 24, Background = Brushes.Transparent, Child = close, VerticalAlignment = VerticalAlignment.Center, Cursor = new Cursor(StandardCursorType.Hand) };
+ AutomationProperties.SetName(remove, $""Remove {file.Name}"");
+ remove.PointerEntered += (_, _) => close.Token = ShellToken.Destructive;
+ remove.PointerExited += (_, _) => close.Token = ShellToken.MutedForeground;
+ remove.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ if (IsEffectivelyEnabled) Remove(file);
+ e.Handled = true;
+ };
+ Grid.SetColumn(remove, 2);
+
+ var row = new Border
+ {
+ Child = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions(""Auto,*,Auto""),
+ ColumnSpacing = 8,
+ Children = { new Icon { Kind = IconName.File, Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Center }, name, remove }
+ },
+ Height = 40,
+ Padding = new Thickness(12, 0, 8, 0),
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd)
+ };
+ row.Token(BorderBrushProperty, ShellToken.Border);
+ return row;
+ }
+
+ private void Update()
+ {
+ _text.Text = Text ?? string.Empty;
+ _hint.Text = MaxFiles > 1 ? $""Select up to {MaxFiles} files"" : string.Empty;
+ _hint.IsVisible = Multiple && MaxFiles > 1;
+ AutomationProperties.SetName(this, Text ?? string.Empty);
+ Paint();
+ }
+
+ private void Paint()
+ {
+ var active = _dragOver || (_zone.IsPointerOver && IsEffectivelyEnabled);
+ _outline.Token(Shape.StrokeProperty, _dragOver ? ShellToken.Primary : active ? ShellToken.Ring : ShellToken.Border);
+ if (active) _outline.Token(Shape.FillProperty, ShellToken.Muted);
+ else
+ {
+ _outline.ClearToken(Shape.FillProperty);
+ _outline.Fill = null;
+ }
+ Opacity = IsEnabled ? 1.0 : 0.5;
+ }
+}
+"
+ };
+}
diff --git a/src/ShellUI.Native.Templates/Templates/NavbarTemplate.cs b/src/ShellUI.Native.Templates/Templates/NavbarTemplate.cs
new file mode 100644
index 0000000..e196698
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/NavbarTemplate.cs
@@ -0,0 +1,718 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class NavbarTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "navbar",
+ DisplayName = "Navbar",
+ Description = "Top navigation bar that collapses its links into a menu",
+ Category = ComponentCategory.Navigation,
+ FilePath = "Navbar.cs",
+ Dependencies = new List { "shell", "icon", "element-extensions" },
+ Tags = new List { "navbar", "navigation", "header", "menu" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"using System.Collections.ObjectModel;
+using Microsoft.Maui.Controls.Shapes;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Top navigation bar — h-14 border-b bg-background: a brand (icon and title), links, and actions
+// at the end. Narrower than CollapseBelow, the links move into a menu that a button opens under
+// the bar.
+//
+//
+//
+//
+//
+//
+//
+[ContentProperty(nameof(Items))]
+public partial class Navbar : ContentView
+{
+ public static readonly BindableProperty TitleProperty =
+ BindableProperty.Create(nameof(Title), typeof(string), typeof(Navbar), string.Empty,
+ propertyChanged: (b, o, n) => ((Navbar)b).UpdateBrand());
+
+ public static readonly BindableProperty IconProperty =
+ BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(Navbar), IconName.None,
+ propertyChanged: (b, o, n) => ((Navbar)b).UpdateBrand());
+
+ // Replaces the icon and title.
+ public static readonly BindableProperty BrandProperty =
+ BindableProperty.Create(nameof(Brand), typeof(View), typeof(Navbar), null,
+ propertyChanged: (b, o, n) => ((Navbar)b).UpdateBrand());
+
+ // Shown at the end of the bar (buttons, an avatar, a theme toggle).
+ public static readonly BindableProperty ActionsProperty =
+ BindableProperty.Create(nameof(Actions), typeof(View), typeof(Navbar), null,
+ propertyChanged: (b, o, n) => ((Navbar)b)._actions.Content = n as View);
+
+ // The active item's Value.
+ public static readonly BindableProperty SelectedValueProperty =
+ BindableProperty.Create(nameof(SelectedValue), typeof(string), typeof(Navbar), null, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((Navbar)b).OnSelectedValueChanged());
+
+ // Below this width the links collapse into the menu. 0 never collapses.
+ public static readonly BindableProperty CollapseBelowProperty =
+ BindableProperty.Create(nameof(CollapseBelow), typeof(double), typeof(Navbar), 640d,
+ propertyChanged: (b, o, n) => ((Navbar)b).UpdateLayout());
+
+ public static readonly BindableProperty IsMenuOpenProperty =
+ BindableProperty.Create(nameof(IsMenuOpen), typeof(bool), typeof(Navbar), false, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((Navbar)b).UpdateLayout());
+
+ public string Title
+ {
+ get => (string)GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => (IconName)GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public View? Brand
+ {
+ get => (View?)GetValue(BrandProperty);
+ set => SetValue(BrandProperty, value);
+ }
+
+ public View? Actions
+ {
+ get => (View?)GetValue(ActionsProperty);
+ set => SetValue(ActionsProperty, value);
+ }
+
+ public string? SelectedValue
+ {
+ get => (string?)GetValue(SelectedValueProperty);
+ set => SetValue(SelectedValueProperty, value);
+ }
+
+ public double CollapseBelow
+ {
+ get => (double)GetValue(CollapseBelowProperty);
+ set => SetValue(CollapseBelowProperty, value);
+ }
+
+ public bool IsMenuOpen
+ {
+ get => (bool)GetValue(IsMenuOpenProperty);
+ set => SetValue(IsMenuOpenProperty, value);
+ }
+
+ public IList Items => _items;
+
+ public event EventHandler? SelectedValueChanged;
+
+ private readonly ObservableCollection _items = new();
+ private readonly Icon _brandIcon;
+ private readonly Label _title;
+ private readonly ContentView _brand;
+ private readonly HorizontalStackLayout _brandRow;
+ private readonly HorizontalStackLayout _links;
+ private readonly ContentView _actions;
+ private readonly Border _menuButton;
+ private readonly Icon _menuIcon;
+ private readonly VerticalStackLayout _menu;
+ private bool _collapsed;
+
+ public Navbar()
+ {
+ _brandIcon = new Icon { Size = 20, Token = ShellToken.Foreground, VerticalOptions = LayoutOptions.Center, IsVisible = false };
+ _title = new Label { FontSize = 16, FontAttributes = FontAttributes.Bold, VerticalTextAlignment = TextAlignment.Center, LineBreakMode = LineBreakMode.NoWrap };
+ _title.Token(Label.TextColorProperty, ShellToken.Foreground);
+ _brandRow = new HorizontalStackLayout { Spacing = 8, Children = { _brandIcon, _title } };
+ _brand = new ContentView { VerticalOptions = LayoutOptions.Center, Content = _brandRow };
+ _links = new HorizontalStackLayout { Spacing = 4, VerticalOptions = LayoutOptions.Center, Margin = new Thickness(16, 0, 0, 0) };
+ _actions = new ContentView { VerticalOptions = LayoutOptions.Center };
+
+ _menuIcon = new Icon { Name = IconName.Menu, Size = 18, Token = ShellToken.Foreground };
+ _menuButton = new Border
+ {
+ Content = _menuIcon,
+ WidthRequest = 36,
+ HeightRequest = 36,
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ BackgroundColor = Colors.Transparent,
+ VerticalOptions = LayoutOptions.Center,
+ Margin = new Thickness(4, 0, 0, 0),
+ IsVisible = false
+ };
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => _menuButton.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent);
+ pointer.PointerExited += (_, _) => { _menuButton.ClearValue(VisualElement.BackgroundColorProperty); _menuButton.BackgroundColor = Colors.Transparent; };
+ _menuButton.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => IsMenuOpen = !IsMenuOpen;
+ _menuButton.GestureRecognizers.Add(tap);
+
+ var bar = new Grid
+ {
+ HeightRequest = 56,
+ Padding = new Thickness(16, 0),
+ ColumnDefinitions =
+ {
+ new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star),
+ new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Auto)
+ }
+ };
+ bar.Add(_brand, 0, 0);
+ bar.Add(_links, 1, 0);
+ bar.Add(_actions, 2, 0);
+ bar.Add(_menuButton, 3, 0);
+
+ _menu = new VerticalStackLayout { Spacing = 2, Padding = new Thickness(12, 0, 12, 12), IsVisible = false };
+ var edge = new BoxView { HeightRequest = 1, BackgroundColor = Colors.Transparent };
+ edge.Token(BoxView.ColorProperty, ShellToken.Border);
+
+ var root = new VerticalStackLayout { Spacing = 0, Children = { bar, _menu, edge } };
+ root.Token(VisualElement.BackgroundColorProperty, ShellToken.Background);
+ Content = root;
+
+ _items.CollectionChanged += (_, _) => UpdateLayout(force: true);
+ SizeChanged += (_, _) => UpdateLayout();
+ UpdateBrand();
+ }
+
+ internal void Select(NavbarItem item)
+ {
+ if (!string.IsNullOrEmpty(item.Value)) SelectedValue = item.Value;
+ IsMenuOpen = false;
+ }
+
+ private void OnSelectedValueChanged()
+ {
+ Refresh();
+ SelectedValueChanged?.Invoke(this, SelectedValue);
+ }
+
+ private void UpdateBrand()
+ {
+ _brandIcon.Name = Icon;
+ _brandIcon.IsVisible = Icon != IconName.None;
+ _title.Text = Title ?? string.Empty;
+ _title.IsVisible = !string.IsNullOrEmpty(Title);
+ _brand.Content = Brand ?? _brandRow;
+ }
+
+ private void UpdateLayout() => UpdateLayout(force: false);
+
+ // The links live in the bar or, when collapsed, in the menu under it.
+ private void UpdateLayout(bool force)
+ {
+ var collapsed = CollapseBelow > 0 && Width > 0 && Width < CollapseBelow && _items.Count > 0;
+ if (collapsed != _collapsed || force)
+ {
+ _collapsed = collapsed;
+ _links.Children.Clear();
+ _menu.Children.Clear();
+ foreach (var item in _items)
+ {
+ if (collapsed) _menu.Children.Add(item);
+ else _links.Children.Add(item);
+ }
+ }
+ _menuButton.IsVisible = _collapsed;
+ _menu.IsVisible = _collapsed && IsMenuOpen;
+ _menuIcon.Name = IsMenuOpen && _collapsed ? IconName.X : IconName.Menu;
+ SemanticProperties.SetDescription(_menuButton, IsMenuOpen ? ""Close menu"" : ""Open menu"");
+ Refresh();
+ }
+
+ // Items don't track the bar's state: the bar pushes it to them.
+ private void Refresh()
+ {
+ foreach (var item in _items)
+ item.Apply(isActive: !string.IsNullOrEmpty(item.Value) && item.Value == SelectedValue, inMenu: _collapsed);
+ }
+}
+
+// A link in a Navbar — text-sm font-medium, muted until hovered or active.
+public partial class NavbarItem : ContentView
+{
+ public static readonly BindableProperty TextProperty =
+ BindableProperty.Create(nameof(Text), typeof(string), typeof(NavbarItem), string.Empty,
+ propertyChanged: (b, o, n) => ((NavbarItem)b).Update());
+
+ // Identifies the item for Navbar.SelectedValue.
+ public static readonly BindableProperty ValueProperty =
+ BindableProperty.Create(nameof(Value), typeof(string), typeof(NavbarItem), null);
+
+ public static readonly BindableProperty IconProperty =
+ BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(NavbarItem), IconName.None,
+ propertyChanged: (b, o, n) => ((NavbarItem)b).Update());
+
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public string? Value
+ {
+ get => (string?)GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => (IconName)GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public event EventHandler? Clicked;
+
+ private readonly Border _border;
+ private readonly Icon _icon;
+ private readonly Label _label;
+ private bool _active;
+ private bool _hovered;
+
+ public NavbarItem()
+ {
+ _icon = new Icon { Size = 16, IsVisible = false, VerticalOptions = LayoutOptions.Center };
+ _label = new Label { FontSize = 14, VerticalTextAlignment = TextAlignment.Center, VerticalOptions = LayoutOptions.Center, LineBreakMode = LineBreakMode.NoWrap };
+ _border = new Border
+ {
+ Content = new HorizontalStackLayout { Spacing = 8, VerticalOptions = LayoutOptions.Center, Children = { _icon, _label } },
+ HeightRequest = 36,
+ Padding = new Thickness(12, 0),
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ BackgroundColor = Colors.Transparent
+ };
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => { _hovered = true; Update(); };
+ pointer.PointerExited += (_, _) => { _hovered = false; Update(); };
+ _border.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { ShellFocus.FocusPressed(this); Press(); };
+ _border.GestureRecognizers.Add(tap);
+ ShellFocus.MakeFocusable(this, Press);
+
+ Content = _border;
+ Update();
+ }
+
+ private void Press()
+ {
+ if (!IsEnabled) return;
+ this.FindParentOfType()?.Select(this);
+ Clicked?.Invoke(this, EventArgs.Empty);
+ }
+
+ internal void Apply(bool isActive, bool inMenu)
+ {
+ _active = isActive;
+ // In the menu each link is a full-width row.
+ HorizontalOptions = inMenu ? LayoutOptions.Fill : LayoutOptions.Start;
+ Update();
+ }
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == IsEnabledProperty.PropertyName)
+ Opacity = IsEnabled ? 1.0 : 0.5;
+ }
+
+ private void Update()
+ {
+ _label.Text = Text ?? string.Empty;
+ _label.FontAttributes = _active ? FontAttributes.Bold : FontAttributes.None;
+ _icon.Name = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+
+ var foreground = _active || _hovered ? ShellToken.Foreground : ShellToken.MutedForeground;
+ _label.Token(Label.TextColorProperty, foreground);
+ _icon.Token = foreground;
+
+ if (_active)
+ _border.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent);
+ else if (_hovered && IsEnabled)
+ _border.Token(VisualElement.BackgroundColorProperty, ShellToken.Muted);
+ else
+ {
+ _border.ClearValue(VisualElement.BackgroundColorProperty);
+ _border.BackgroundColor = Colors.Transparent;
+ }
+ }
+}
+",
+ [NativePlatform.Avalonia] = @"using System;
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Top navigation bar — h-14 border-b bg-background: a brand (icon and title), links, and actions
+// at the end. Narrower than CollapseBelow, the links move into a menu that a button opens under
+// the bar.
+//
+//
+//
+//
+//
+//
+//
+public class Navbar : Border
+{
+ public static readonly StyledProperty TitleProperty =
+ AvaloniaProperty.Register(nameof(Title));
+
+ public static readonly StyledProperty IconProperty =
+ AvaloniaProperty.Register(nameof(Icon));
+
+ // Replaces the icon and title.
+ public static readonly StyledProperty BrandProperty =
+ AvaloniaProperty.Register(nameof(Brand));
+
+ // Shown at the end of the bar (buttons, an avatar, a theme toggle).
+ public static readonly StyledProperty ActionsProperty =
+ AvaloniaProperty.Register(nameof(Actions));
+
+ // The active item's Value.
+ public static readonly StyledProperty