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 @@ + + + +