diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 15a16df..5f8b8f7 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -57,6 +57,28 @@ ShellTheme.Apply(); In code, bind any Color/Brush property to a token with the `Token` extension: `myBorder.Token(Border.StrokeProperty, ShellToken.Border);` +It works on any element, including the spans of mixed text, so their colors change with the +theme (reading `ShellTheme.Get(...)` instead gives a color that stays put until the view is +rebuilt): + +```csharp +label.FormattedText = new FormattedString +{ + Spans = + { + new Span { Text = "Card declined" }.Token(Span.TextColorProperty, ShellToken.Foreground), + new Span { Text = " · 2 min ago" }.Token(Span.TextColorProperty, ShellToken.MutedForeground) + } +}; +// Avalonia: new Run(" · 2 min ago").Token(Run.ForegroundProperty, ShellToken.MutedForeground) +``` + +**The page layer.** Overlays (dialogs, sheets, popups, toasts) draw in a layer above the page: +the page's content is wrapped in a Grid, once, with the layer as its last child. This happens as +the page joins the app, before its views load, so they get a single `Loaded`. A page whose +content is set later (after it was pushed or shown) is wrapped as it appears, which unloads and +loads its views once; set `Content` in the constructor to avoid that. + **Android system bars (opt-in).** Set `ShellTheme.SyncSystemBars = true` before `EnsureInitialized()` to make the status and navigation bars follow the theme: the bars turn transparent over the edge-to-edge page, the page layer draws a `Background`-colored strip behind @@ -168,7 +190,7 @@ Same names, properties and variants as MAUI, except: Stroke icons from [ShellIcons](../../../icons/shell-icons) (Lucide 0.475.0 — the set ShellUI uses), drawn with MAUI shapes: no icon font or package, crisp at any size, theme-aware. The -template ships a curated set of ~110 icons with exact Lucide names (`trash-2` → `Trash2`). +template ships a curated set of ~114 icons with exact Lucide names (`trash-2` → `Trash2`). To include more, add their names to `ICONS` in `scripts/generate-icons.py`, then run it and `scripts/sync-templates.py`. @@ -200,7 +222,7 @@ content (set `HorizontalOptions="Fill"` for a full-width button). shellui-native add button ``` -**Variants:** Default, Secondary, Outline, Destructive, Ghost, Link · **Sizes:** Sm (36), Default (40), Lg (44), Icon (40×40) +**Variants:** Default, Secondary, Outline, Destructive, Ghost, Link · **Sizes:** Sm (36), Default (40), Lg (44), Icon (40×40), Inline (no height or padding) | Property | Type | Default | Description | |----------|------|---------|-------------| @@ -219,6 +241,16 @@ shellui-native add button ``` +`Size="Inline"` (shadcn's `h-auto p-0`) is for a `Link` button inside a line of text or at a +card's edge, where the usual padding would push it in: + +```xml + + +``` + **Events:** `Clicked`. On Windows buttons are keyboard tab stops (Enter/Space activate). --- @@ -301,7 +333,8 @@ shellui-native add input |----------|------|---------|-------------| | Text | string | "" | Value (two-way) | | Placeholder | string | "" | Placeholder text | -| IsPassword | bool | false | Mask input | +| Variant | InputVariant | Default | `Password`: masked, with an eye button that shows the text | +| IsPassword | bool | false | Mask input, without the eye button | | HasError | bool | false | Error state | | IsReadOnly | bool | false | Prevent editing | | MaxLength | int | int.MaxValue | Maximum characters | @@ -309,10 +342,15 @@ shellui-native add input ```xml - + ``` +`Variant="Password"` is a password field with a show / hide button: a ghost icon button inside +the field (`Eye` / `EyeOff`, named "Show password" / "Hide password" for screen readers, a tab +stop). Pressing it keeps the field focused, and the text is masked again when the variant changes. +For a masked field without the button, set `IsPassword`. + **Events:** `TextChanged`, `Completed` --- @@ -322,16 +360,18 @@ An input with something before or after the text, inside the same border: an ico text, or any view. Same height, focus ring and error state as `Input`. ```bash -shellui-native add input-group +shellui-native add input-group # also installs input (for InputVariant) ``` -**Properties:** `Text` (two-way), `Placeholder`, `IsPassword`, `HasError`, `Keyboard` (MAUI), +**Properties:** `Text` (two-way), `Placeholder`, `IsPassword`, `Variant` (`Password`: masked, with +an eye button after the suffix, as on `Input`), `HasError`, `Keyboard` (MAUI), `PrefixIcon`, `SuffixIcon`, `PrefixText`, `SuffixText`, `Prefix`, `Suffix` (a view; replaces the icon and text on that side) · **Events:** `TextChanged`, `Completed` · **Methods:** `Focus()` ```xml + @@ -774,7 +814,9 @@ shellui-native add card # also installs card-header, card-content, card-footer ``` **Properties (Card):** `Variant` (Default, Elevated), `IsPressable` · **Events:** `Clicked` (when pressable) -**CardHeader:** `Title`, `Description` · **CardContent:** any content · **CardFooter:** actions, right-aligned +**CardHeader:** `Title`, `Description`, `Action` (a view at the top right, shadcn's `CardAction`), +and views inside it, which sit on the title's line (badges, a count) and wrap under it when +there is no room · **CardContent:** any content · **CardFooter:** actions, right-aligned ```xml @@ -785,6 +827,16 @@ shellui-native add card # also installs card-header, card-content, card-footer + + + + + + + + + ... + ``` --- @@ -1282,14 +1334,24 @@ Dashboard metric: a muted title with an optional icon, the value in large bold t pill tinted by trend, and an optional description. ```bash -shellui-native add stat-card +shellui-native add stat-card # also installs skeleton ``` -**Properties:** `Title`, `Value`, `Change`, `Trend` (Neutral, Up, Down), `Description`, `Icon` +**Properties:** `Title`, `Value`, `Change`, `Trend` (Neutral, Up, Down), `Description`, `Icon`, +`ValueTone` (Default, Success, Warning, Destructive: tints the value), `IsLoading` (skeletons in +place of the value and description), `Size` (Default, or Sm: p-4 and a smaller value, for six +tiles to a row) + +A long value wraps, and the change pill drops under it instead of being cut off at the card's +edge. ```xml + + + ``` ### Timeline @@ -1373,7 +1435,8 @@ table as it changes), `ShowFilter` (true), `FilterPlaceholder`, `Filter` (the fi **Properties (DataTableColumn):** `Header`, `Property` (the item property to show), `Format` (a .NET format string such as `C` or `MMM d, yyyy`), `Width` (`*`, `2*` or a fixed number), `IsSortable` (true), `IsBold`, `HorizontalTextAlignment`, `Value` (a function that reads the -value; set in code, replaces `Property`) +value; set in code, replaces `Property`), `Cell` (a function that draws the cell as any view; set +in code, replaces the text) ```xml @@ -1385,8 +1448,21 @@ value; set in code, replaces `Property`) The filter matches every typed word against the text of any column. A sortable header cycles ascending, descending and unsorted; numbers and dates sort by value. The header checkbox selects -every row that passes the filter, across pages. Cells show text only: for badges or buttons in -cells, build the rows yourself with `Table`. +every row that passes the filter, across pages. + +For a link, a badge or a formatted amount in a cell, set the column's `Cell` (the counterpart of +ShellUI's `CellTemplate`). The filter and sorting still use `Property` or `Value`, so keep one +set. Set `Cell` before `ItemsSource`, or call `Refresh()` after: + +```csharp +InvoiceTable.Columns[1].Cell = item => new Badge +{ + Text = ((Invoice)item).Status, + Variant = ((Invoice)item).Status == "Paid" ? BadgeVariant.Success : BadgeVariant.Warning, + HorizontalOptions = LayoutOptions.Start // Avalonia: HorizontalAlignment = HorizontalAlignment.Left +}; +InvoiceTable.ItemsSource = invoices; +``` `Property` is read by name at run time. In an app published with trimming or NativeAOT, set `Value` in code instead, so the property is not trimmed away: @@ -1724,6 +1800,22 @@ shellui-native add sheet ``` +A sheet has the same parts: `SheetHeader` (title and description stack), `SheetTitle`, +`SheetDescription` and `SheetFooter` (actions on the right), installed with `sheet`: + +```xml + + + + + + + ... + + + +``` + `DialogTrigger` / `DrawerTrigger` / `SheetTrigger` and `DialogClose` wrap a Button or any view. The trigger is optional: open from code with `EditDialog.SetOpen(true)`. diff --git a/docs/COMPONENTS_ROADMAP.md b/docs/COMPONENTS_ROADMAP.md index 11796d3..0b3475b 100644 --- a/docs/COMPONENTS_ROADMAP.md +++ b/docs/COMPONENTS_ROADMAP.md @@ -223,6 +223,7 @@ Icons: `python scripts/generate-icons.py` regenerates `Icon.cs` from the ShellIc | **blocks** ✅ | — | Done 2026-10-16, MAUI and Avalonia — numbered presets like ShellUI's `dashboard-01`, installed to `Components/Blocks`: `bottom-nav-01`…`05`, `edge-menu-01` | | **edge-menu** ✅ | — | Done 2026-10-16, MAUI and Avalonia — native only: a handle on a screen edge slides out a rail of tool tiles; Default / Solid, left or right, optional dimming | | **bottom-nav** ✅ | — | Done 2026-10-16, MAUI and Avalonia — native only (ShellUI for Blazor has none): Default, Pill, Floating, Lifted and Expanding styles, badges and dots, a raised action button, avatar items, hide on scroll, the safe area under the bar | +| **Slots and states** ✅ | — | Done 2026-10-18, MAUI and Avalonia — `DataTableColumn.Cell` (views in cells), `ButtonSize.Inline`, `SheetHeader` / `SheetTitle` / `SheetDescription` / `SheetFooter`, `CardHeader.Action` and title-line items, `StatCard.ValueTone` / `IsLoading` / `Size` and a wrapping value row, `Variant="Password"` (show / hide button) on input and input-group, `Receipt` / `ListChecks` / `CalendarClock` / `KeyRound` icons, theme tokens on spans, and (MAUI) the page layer set up before a page's views load | --- diff --git a/examples/Avalonia.Demo/Components/UI/CardHeader.cs b/examples/Avalonia.Demo/Components/UI/CardHeader.cs index c606952..98f9dcf 100644 --- a/examples/Avalonia.Demo/Components/UI/CardHeader.cs +++ b/examples/Avalonia.Demo/Components/UI/CardHeader.cs @@ -1,10 +1,20 @@ using Avalonia; using Avalonia.Controls; +using Avalonia.Layout; using Avalonia.Media; +using Avalonia.Metadata; namespace AvaloniaDemo.Components.UI; // Card header — p-6 space-y-1.5; title font-semibold, description text-sm text-muted-foreground. +// Controls inside sit on the title's line (badges, a count) and wrap under it when there is no +// room; Action sits at the top right (shadcn's CardAction). +// +// +// +// +// +// public class CardHeader : Border { public static readonly StyledProperty TitleProperty = @@ -13,13 +23,19 @@ public class CardHeader : Border public static readonly StyledProperty DescriptionProperty = AvaloniaProperty.Register(nameof(Description)); + public static readonly StyledProperty ActionProperty = + AvaloniaProperty.Register(nameof(Action)); + private readonly TextBlock _title; private readonly TextBlock _description; + private readonly WrapPanel _titleLine; + private readonly ContentControl _action; static CardHeader() { TitleProperty.Changed.AddClassHandler((h, _) => h.UpdateText()); DescriptionProperty.Changed.AddClassHandler((h, _) => h.UpdateText()); + ActionProperty.Changed.AddClassHandler((h, _) => h.UpdateAction()); } public string? Title @@ -34,21 +50,56 @@ public string? Description set => SetValue(DescriptionProperty, value); } + // Any control at the top right: a button, a menu, a select. + public Control? Action + { + get => GetValue(ActionProperty); + set => SetValue(ActionProperty, value); + } + + // Controls after the title, on its line. + [Content] + public Controls Items { get; } = new(); + public CardHeader() { - _title = new TextBlock { FontSize = 18, FontWeight = FontWeight.SemiBold, TextWrapping = TextWrapping.Wrap }; + _title = new TextBlock { FontSize = 18, FontWeight = FontWeight.SemiBold, TextWrapping = TextWrapping.Wrap, IsVisible = false, VerticalAlignment = VerticalAlignment.Center }; _title.Token(TextBlock.ForegroundProperty, ShellToken.CardForeground); _description = new TextBlock { FontSize = 14, TextWrapping = TextWrapping.Wrap, IsVisible = false }; _description.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); - Child = new StackPanel { Spacing = 6, Children = { _title, _description } }; + _titleLine = new WrapPanel { ItemSpacing = 8, LineSpacing = 4, Children = { _title } }; + Items.CollectionChanged += (_, _) => + { + while (_titleLine.Children.Count > 1) _titleLine.Children.RemoveAt(1); + foreach (var item in Items) + { + item.VerticalAlignment = VerticalAlignment.Center; + _titleLine.Children.Add(item); + } + }; + _action = new ContentControl { VerticalAlignment = VerticalAlignment.Top, IsVisible = false }; + + var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto"), ColumnSpacing = 16 }; + var text = new StackPanel { Spacing = 6, Children = { _titleLine, _description } }; + grid.Children.Add(text); + Grid.SetColumn(_action, 1); + grid.Children.Add(_action); + Child = grid; Padding = new Thickness(24, 24, 24, 16); } private void UpdateText() { _title.Text = Title ?? string.Empty; + _title.IsVisible = !string.IsNullOrEmpty(Title); _description.Text = Description ?? string.Empty; _description.IsVisible = !string.IsNullOrEmpty(Description); } + + private void UpdateAction() + { + _action.Content = Action; + _action.IsVisible = Action != null; + } } diff --git a/examples/Avalonia.Demo/Components/UI/DataTable.cs b/examples/Avalonia.Demo/Components/UI/DataTable.cs index 5c4feae..dc2f123 100644 --- a/examples/Avalonia.Demo/Components/UI/DataTable.cs +++ b/examples/Avalonia.Demo/Components/UI/DataTable.cs @@ -28,6 +28,8 @@ namespace AvaloniaDemo.Components.UI; // // A column reads its Property from each item by name. With trimming or NativeAOT, set the // column's Value in code instead: column.Value = item => ((Invoice)item).Number; +// For a view in a cell (a badge, a link), set Cell in code: +// column.Cell = item => new Badge { Text = ((Invoice)item).Status }; (before ItemsSource) public class DataTable : Border { public static readonly StyledProperty ItemsSourceProperty = @@ -304,7 +306,9 @@ private void Render(IReadOnlyList rows) } _rowViews.Add((item, row, check)); foreach (var column in Columns) - row.Children.Add(new TableCell { Text = column.Text(item), IsBold = column.IsBold, HorizontalTextAlignment = column.HorizontalTextAlignment }); + row.Children.Add(column.Cell is { } cell + ? new TableCell { Child = cell(item) } + : new TableCell { Text = column.Text(item), IsBold = column.IsBold, HorizontalTextAlignment = column.HorizontalTextAlignment }); _table.Rows.Add(row); } @@ -449,6 +453,11 @@ public class DataTableColumn // Reads the column's value from an item; replaces Property. public Func? Value { get; set; } + // Draws the cell as any control (a link, a badge, an amount); replaces the text. Filter and sort + // still use Value or Property, so set one of them for a column that sorts or is searched. Set it + // before ItemsSource, or call Refresh() after. + public Func? Cell { get; set; } + // A .NET format string for the value, e.g. "C" or "MMM d, yyyy". public string? Format { get; set; } diff --git a/examples/Avalonia.Demo/Components/UI/Icon.cs b/examples/Avalonia.Demo/Components/UI/Icon.cs index ece85cc..e438852 100644 --- a/examples/Avalonia.Demo/Components/UI/Icon.cs +++ b/examples/Avalonia.Demo/Components/UI/Icon.cs @@ -9,7 +9,7 @@ namespace AvaloniaDemo.Components.UI; // Stroke icons from ShellIcons (https://github.com/shellui-dev/shell-icons), derived from // Lucide 0.475.0 (ISC license) — the icon set ShellUI uses. Drawn as Avalonia geometry: no font or // package, crisp at any size, theme-aware. Generated by scripts/generate-icons.py; a curated set -// of 110 icons — add names there and re-run to include more. +// of 114 icons — add names there and re-run to include more. // Usage: Tint with Token="MutedForeground" or Color="Red". // The icon is picked with Kind: every Avalonia control already has a Name. public enum IconName @@ -81,6 +81,7 @@ public enum IconName Lock, LockOpen, Key, + KeyRound, Shield, Eye, EyeOff, @@ -90,12 +91,15 @@ public enum IconName MessageCircle, Phone, Calendar, + CalendarClock, Clock, Globe, MapPin, Image, File, FileText, + Receipt, + ListChecks, Folder, Tag, Bookmark, @@ -198,6 +202,7 @@ public static class IconPaths IconName.Lock => "M 5 11 L 19 11 C 20.105 11 21 11.895 21 13 L 21 20 C 21 21.105 20.105 22 19 22 L 5 22 C 3.895 22 3 21.105 3 20 L 3 13 C 3 11.895 3.895 11 5 11 Z M 7 11 L 7 7 C 7 4.239 9.239 2 12 2 C 14.761 2 17 4.239 17 7 L 17 11", IconName.LockOpen => "M 5 11 L 19 11 C 20.105 11 21 11.895 21 13 L 21 20 C 21 21.105 20.105 22 19 22 L 5 22 C 3.895 22 3 21.105 3 20 L 3 13 C 3 11.895 3.895 11 5 11 Z M 7 11 L 7 7 C 6.997 4.431 8.942 2.278 11.498 2.02 C 14.053 1.762 16.389 3.483 16.9 6", IconName.Key => "M 15.5 7.5 L 17.8 9.8 C 18.189 10.181 18.811 10.181 19.2 9.8 L 21.3 7.7 C 21.681 7.311 21.681 6.689 21.3 6.3 L 19 4 M 21 2 L 11.4 11.6 M 13 15.5 C 13 18.538 10.538 21 7.5 21 C 4.462 21 2 18.538 2 15.5 C 2 12.462 4.462 10 7.5 10 C 10.538 10 13 12.462 13 15.5 Z", + IconName.KeyRound => "M 2.586 17.414 C 2.211 17.789 2 18.298 2 18.828 L 2 21 C 2 21.552 2.448 22 3 22 L 6 22 C 6.552 22 7 21.552 7 21 L 7 20 C 7 19.448 7.448 19 8 19 L 9 19 C 9.552 19 10 18.552 10 18 L 10 17 C 10 16.448 10.448 16 11 16 L 11.172 16 C 11.702 16 12.211 15.789 12.586 15.414 L 13.4 14.6 C 16.262 15.597 19.434 14.484 21.044 11.916 C 22.655 9.349 22.278 6.009 20.134 3.866 C 17.991 1.722 14.651 1.345 12.084 2.956 C 9.516 4.566 8.403 7.738 9.4 10.6 Z M 17 7.5 C 17 7.776 16.776 8 16.5 8 C 16.224 8 16 7.776 16 7.5 C 16 7.224 16.224 7 16.5 7 C 16.776 7 17 7.224 17 7.5 Z", IconName.Shield => "M 20 13 C 20 18 16.5 20.5 12.34 21.95 C 12.122 22.024 11.886 22.02 11.67 21.94 C 7.5 20.5 4 18 4 13 L 4 6 C 4 5.448 4.448 5 5 5 C 7 5 9.5 3.8 11.24 2.28 C 11.678 1.906 12.322 1.906 12.76 2.28 C 14.51 3.81 17 5 19 5 C 19.552 5 20 5.448 20 6 Z", IconName.Eye => "M 2.062 12.348 C 1.979 12.123 1.979 11.877 2.062 11.652 C 3.722 7.627 7.646 5.001 12 5.001 C 16.354 5.001 20.278 7.627 21.938 11.652 C 22.021 11.877 22.021 12.123 21.938 12.348 C 20.278 16.373 16.354 18.999 12 18.999 C 7.646 18.999 3.722 16.373 2.062 12.348 M 15 12 C 15 13.657 13.657 15 12 15 C 10.343 15 9 13.657 9 12 C 9 10.343 10.343 9 12 9 C 13.657 9 15 10.343 15 12 Z", IconName.EyeOff => "M 10.733 5.076 C 15.52 4.506 20.101 7.194 21.938 11.651 C 22.021 11.876 22.021 12.122 21.938 12.347 C 21.57 13.238 21.085 14.076 20.494 14.837 M 14.084 14.158 C 12.907 15.295 11.036 15.279 9.879 14.121 C 8.721 12.964 8.705 11.093 9.842 9.916 M 17.479 17.499 C 14.795 19.089 11.553 19.434 8.594 18.446 C 5.635 17.457 3.251 15.232 2.062 12.348 C 1.979 12.123 1.979 11.877 2.062 11.652 C 2.949 9.502 4.509 7.697 6.508 6.509 M 2 2 L 22 22", @@ -207,12 +212,15 @@ public static class IconPaths IconName.MessageCircle => "M 7.9 20 C 11.843 22.023 16.672 20.874 19.282 17.292 C 21.892 13.71 21.505 8.762 18.372 5.628 C 15.238 2.495 10.29 2.108 6.708 4.718 C 3.126 7.328 1.977 12.157 4 16.1 L 2 22 Z", IconName.Phone => "M 22 16.92 L 22 19.92 C 22.002 20.483 21.767 21.021 21.352 21.402 C 20.937 21.783 20.381 21.971 19.82 21.92 C 16.743 21.586 13.787 20.534 11.19 18.85 C 8.774 17.315 6.725 15.266 5.19 12.85 C 3.5 10.241 2.448 7.271 2.12 4.18 C 2.069 3.621 2.256 3.066 2.635 2.652 C 3.013 2.237 3.549 2.001 4.11 2 L 7.11 2 C 8.114 1.99 8.97 2.726 9.11 3.72 C 9.237 4.68 9.471 5.623 9.81 6.53 C 10.085 7.261 9.909 8.085 9.36 8.64 L 8.09 9.91 C 9.514 12.414 11.586 14.486 14.09 15.91 L 15.36 14.64 C 15.915 14.091 16.739 13.915 17.47 14.19 C 18.377 14.529 19.32 14.763 20.28 14.89 C 21.286 15.032 22.025 15.905 22 16.92 Z", IconName.Calendar => "M 8 2 L 8 6 M 16 2 L 16 6 M 5 4 L 19 4 C 20.105 4 21 4.895 21 6 L 21 20 C 21 21.105 20.105 22 19 22 L 5 22 C 3.895 22 3 21.105 3 20 L 3 6 C 3 4.895 3.895 4 5 4 Z M 3 10 L 21 10", + IconName.CalendarClock => "M 21 7.5 L 21 6 C 21 4.895 20.105 4 19 4 L 5 4 C 3.895 4 3 4.895 3 6 L 3 20 C 3 21.105 3.895 22 5 22 L 8.5 22 M 16 2 L 16 6 M 8 2 L 8 6 M 3 10 L 8 10 M 17.5 17.5 L 16 16.3 L 16 14 M 22 16 C 22 19.314 19.314 22 16 22 C 12.686 22 10 19.314 10 16 C 10 12.686 12.686 10 16 10 C 19.314 10 22 12.686 22 16 Z", IconName.Clock => "M 22 12 C 22 17.523 17.523 22 12 22 C 6.477 22 2 17.523 2 12 C 2 6.477 6.477 2 12 2 C 17.523 2 22 6.477 22 12 Z M 12 6 L 12 12 L 16 14", IconName.Globe => "M 22 12 C 22 17.523 17.523 22 12 22 C 6.477 22 2 17.523 2 12 C 2 6.477 6.477 2 12 2 C 17.523 2 22 6.477 22 12 Z M 12 2 C 6.667 7.6 6.667 16.4 12 22 C 17.333 16.4 17.333 7.6 12 2 M 2 12 L 22 12", IconName.MapPin => "M 20 10 C 20 14.993 14.461 20.193 12.601 21.799 C 12.245 22.067 11.755 22.067 11.399 21.799 C 9.539 20.193 4 14.993 4 10 C 4 5.582 7.582 2 12 2 C 16.418 2 20 5.582 20 10 M 15 10 C 15 11.657 13.657 13 12 13 C 10.343 13 9 11.657 9 10 C 9 8.343 10.343 7 12 7 C 13.657 7 15 8.343 15 10 Z", IconName.Image => "M 5 3 L 19 3 C 20.105 3 21 3.895 21 5 L 21 19 C 21 20.105 20.105 21 19 21 L 5 21 C 3.895 21 3 20.105 3 19 L 3 5 C 3 3.895 3.895 3 5 3 Z M 11 9 C 11 10.105 10.105 11 9 11 C 7.895 11 7 10.105 7 9 C 7 7.895 7.895 7 9 7 C 10.105 7 11 7.895 11 9 Z M 21 15 L 17.914 11.914 C 17.133 11.133 15.867 11.133 15.086 11.914 L 6 21", IconName.File => "M 15 2 L 6 2 C 4.895 2 4 2.895 4 4 L 4 20 C 4 21.105 4.895 22 6 22 L 18 22 C 19.105 22 20 21.105 20 20 L 20 7 Z M 14 2 L 14 6 C 14 7.105 14.895 8 16 8 L 20 8", IconName.FileText => "M 15 2 L 6 2 C 4.895 2 4 2.895 4 4 L 4 20 C 4 21.105 4.895 22 6 22 L 18 22 C 19.105 22 20 21.105 20 20 L 20 7 Z M 14 2 L 14 6 C 14 7.105 14.895 8 16 8 L 20 8 M 10 9 L 8 9 M 16 13 L 8 13 M 16 17 L 8 17", + IconName.Receipt => "M 4 2 L 4 22 L 6 21 L 8 22 L 10 21 L 12 22 L 14 21 L 16 22 L 18 21 L 20 22 L 20 2 L 18 3 L 16 2 L 14 3 L 12 2 L 10 3 L 8 2 L 6 3 Z M 16 8 L 10 8 C 8.895 8 8 8.895 8 10 C 8 11.105 8.895 12 10 12 L 14 12 C 15.105 12 16 12.895 16 14 C 16 15.105 15.105 16 14 16 L 8 16 M 12 17.5 L 12 6.5", + IconName.ListChecks => "M 3 17 L 5 19 L 9 15 M 3 7 L 5 9 L 9 5 M 13 6 L 21 6 M 13 12 L 21 12 M 13 18 L 21 18", IconName.Folder => "M 20 20 C 21.105 20 22 19.105 22 18 L 22 8 C 22 6.895 21.105 6 20 6 L 12.1 6 C 11.42 6.007 10.784 5.668 10.41 5.1 L 9.6 3.9 C 9.23 3.338 8.603 3 7.93 3 L 4 3 C 2.895 3 2 3.895 2 5 L 2 18 C 2 19.105 2.895 20 4 20 Z", IconName.Tag => "M 12.586 2.586 C 12.211 2.211 11.702 2 11.172 2 L 4 2 C 2.895 2 2 2.895 2 4 L 2 11.172 C 2 11.702 2.211 12.211 2.586 12.586 L 11.29 21.29 C 12.236 22.23 13.764 22.23 14.71 21.29 L 21.29 14.71 C 22.23 13.764 22.23 12.236 21.29 11.29 Z M 8 7.5 C 8 7.776 7.776 8 7.5 8 C 7.224 8 7 7.776 7 7.5 C 7 7.224 7.224 7 7.5 7 C 7.776 7 8 7.224 8 7.5 Z", IconName.Bookmark => "M 19 21 L 12 17 L 5 21 L 5 5 C 5 3.895 5.895 3 7 3 L 17 3 C 18.105 3 19 3.895 19 5 L 19 21 Z", diff --git a/examples/Avalonia.Demo/Components/UI/Input.cs b/examples/Avalonia.Demo/Components/UI/Input.cs index cafd44f..b9fe000 100644 --- a/examples/Avalonia.Demo/Components/UI/Input.cs +++ b/examples/Avalonia.Demo/Components/UI/Input.cs @@ -1,5 +1,6 @@ using System; using Avalonia; +using Avalonia.Automation; using Avalonia.Controls; using Avalonia.Data; using Avalonia.Input; @@ -11,6 +12,7 @@ namespace AvaloniaDemo.Components.UI; // Text input — h-10 rounded-md border border-input px-3 text-sm. // Focus: border-ring + soft ring glow. Error: border-destructive. // The inner TextBox's Fluent chrome is stripped so this Border is the only frame. +// Variant="Password" masks the text and adds an eye button that shows it; IsPassword only masks. public class Input : Border { public static readonly StyledProperty TextProperty = @@ -22,6 +24,9 @@ public class Input : Border public static readonly StyledProperty IsPasswordProperty = AvaloniaProperty.Register(nameof(IsPassword)); + public static readonly StyledProperty VariantProperty = + AvaloniaProperty.Register(nameof(Variant)); + public static readonly StyledProperty HasErrorProperty = AvaloniaProperty.Register(nameof(HasError)); @@ -32,6 +37,9 @@ public class Input : Border AvaloniaProperty.Register(nameof(MaxLength)); private readonly TextBox _textBox; + private readonly Border _reveal; + private readonly Icon _revealIcon; + private bool _revealed; private bool _isFocused; static Input() @@ -41,7 +49,8 @@ static Input() if (i._textBox.Text != (string?)e.NewValue) i._textBox.Text = (string?)e.NewValue; }); PlaceholderProperty.Changed.AddClassHandler((i, e) => i._textBox.PlaceholderText = (string?)e.NewValue); - IsPasswordProperty.Changed.AddClassHandler((i, e) => i._textBox.PasswordChar = (bool)e.NewValue! ? '•' : default); + IsPasswordProperty.Changed.AddClassHandler((i, _) => i.UpdatePassword()); + VariantProperty.Changed.AddClassHandler((i, _) => i.UpdatePassword()); IsReadOnlyProperty.Changed.AddClassHandler((i, e) => i._textBox.IsReadOnly = (bool)e.NewValue!); MaxLengthProperty.Changed.AddClassHandler((i, e) => i._textBox.MaxLength = (int)e.NewValue!); HasErrorProperty.Changed.AddClassHandler((i, _) => i.UpdateVisualState()); @@ -66,6 +75,12 @@ public bool IsPassword set => SetValue(IsPasswordProperty, value); } + public InputVariant Variant + { + get => GetValue(VariantProperty); + set => SetValue(VariantProperty, value); + } + public bool HasError { get => GetValue(HasErrorProperty); @@ -112,7 +127,9 @@ public Input() _textBox.GotFocus += (_, _) => { _isFocused = true; UpdateVisualState(); }; _textBox.LostFocus += (_, _) => { _isFocused = false; UpdateVisualState(); }; - Child = _textBox; + (_reveal, _revealIcon) = CreateRevealButton(); + Grid.SetColumn(_reveal, 1); + Child = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto"), Children = { _textBox, _reveal } }; Height = 40; Padding = new Thickness(12, 0); BorderThickness = new Thickness(1); @@ -147,6 +164,58 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) public new bool Focus(NavigationMethod method = NavigationMethod.Unspecified, KeyModifiers keyModifiers = KeyModifiers.None) => _textBox.Focus(method, keyModifiers); + // A ghost icon button, h-7 w-7, that pulls into the padding a little. + private (Border, Icon) CreateRevealButton() + { + var icon = new Icon { Kind = IconName.Eye, Size = 16, Token = ShellToken.MutedForeground }; + var button = new Border + { + Child = icon, + Width = 28, + Height = 28, + CornerRadius = new CornerRadius(ShellTheme.RadiusSm), + Background = Brushes.Transparent, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(4, 0, -6, 0), + Cursor = new Cursor(StandardCursorType.Hand), + IsVisible = false + }; + AutomationProperties.SetName(button, "Show password"); + button.PointerEntered += (_, _) => button.Token(BackgroundProperty, ShellToken.Accent); + button.PointerExited += (_, _) => { button.ClearToken(BackgroundProperty); button.Background = Brushes.Transparent; }; + // Handled, so the press neither focuses the button nor leaves the text box. + button.PointerPressed += (_, e) => + { + ToggleReveal(); + _textBox.Focus(); + e.Handled = true; + }; + ShellFocus.Ring(button, button); + button.KeyDown += (_, e) => + { + if (e.Key is not (Key.Enter or Key.Space)) return; + ToggleReveal(); + e.Handled = true; + }; + return (button, icon); + } + + private void ToggleReveal() + { + _revealed = !_revealed; + UpdatePassword(); + } + + private void UpdatePassword() + { + var password = Variant == InputVariant.Password; + if (!password) _revealed = false; + _textBox.PasswordChar = (IsPassword || password) && !_revealed ? '•' : default; + _reveal.IsVisible = password; + _revealIcon.Kind = _revealed ? IconName.EyeOff : IconName.Eye; + AutomationProperties.SetName(_reveal, _revealed ? "Hide password" : "Show password"); + } + private void UpdateVisualState() { var stroke = HasError ? ShellToken.Destructive : _isFocused ? ShellToken.Ring : ShellToken.Input; @@ -156,3 +225,6 @@ private void UpdateVisualState() : default; } } + +// Password masks the text and shows an eye button that reveals it. +public enum InputVariant { Default, Password } diff --git a/examples/Avalonia.Demo/Components/UI/InputGroup.cs b/examples/Avalonia.Demo/Components/UI/InputGroup.cs index 4f1565c..4be1633 100644 --- a/examples/Avalonia.Demo/Components/UI/InputGroup.cs +++ b/examples/Avalonia.Demo/Components/UI/InputGroup.cs @@ -1,5 +1,6 @@ using System; using Avalonia; +using Avalonia.Automation; using Avalonia.Controls; using Avalonia.Data; using Avalonia.Input; @@ -15,6 +16,7 @@ namespace AvaloniaDemo.Components.UI; // // ...any control... // +// Variant="Password" masks the text and adds an eye button after the suffix that shows it; IsPassword only masks. public class InputGroup : Border { public static readonly StyledProperty TextProperty = @@ -26,6 +28,9 @@ public class InputGroup : Border public static readonly StyledProperty IsPasswordProperty = AvaloniaProperty.Register(nameof(IsPassword)); + public static readonly StyledProperty VariantProperty = + AvaloniaProperty.Register(nameof(Variant)); + public static readonly StyledProperty HasErrorProperty = AvaloniaProperty.Register(nameof(HasError)); @@ -50,6 +55,9 @@ public class InputGroup : Border AvaloniaProperty.Register(nameof(Suffix)); private readonly TextBox _textBox; + private readonly Border _reveal; + private readonly Icon _revealIcon; + private bool _revealed; private readonly Border _prefix = new() { VerticalAlignment = VerticalAlignment.Center, IsVisible = false }; private readonly Border _suffix = new() { VerticalAlignment = VerticalAlignment.Center, IsVisible = false }; private bool _isFocused; @@ -61,7 +69,8 @@ static InputGroup() if (g._textBox.Text != (string?)e.NewValue) g._textBox.Text = (string?)e.NewValue; }); PlaceholderProperty.Changed.AddClassHandler((g, e) => g._textBox.PlaceholderText = (string?)e.NewValue); - IsPasswordProperty.Changed.AddClassHandler((g, e) => g._textBox.PasswordChar = (bool)e.NewValue! ? '•' : default); + IsPasswordProperty.Changed.AddClassHandler((g, _) => g.UpdatePassword()); + VariantProperty.Changed.AddClassHandler((g, _) => g.UpdatePassword()); HasErrorProperty.Changed.AddClassHandler((g, _) => g.UpdateVisualState()); IsEnabledProperty.Changed.AddClassHandler((g, _) => g.Opacity = g.IsEnabled ? 1.0 : 0.5); PrefixIconProperty.Changed.AddClassHandler((g, _) => g.UpdateSlots()); @@ -90,6 +99,12 @@ public bool IsPassword set => SetValue(IsPasswordProperty, value); } + public InputVariant Variant + { + get => GetValue(VariantProperty); + set => SetValue(VariantProperty, value); + } + public bool HasError { get => GetValue(HasErrorProperty); @@ -160,11 +175,13 @@ public InputGroup() Grid.SetColumn(_textBox, 1); Grid.SetColumn(_suffix, 2); + (_reveal, _revealIcon) = CreateRevealButton(); + Grid.SetColumn(_reveal, 3); Child = new Grid { - ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"), + ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto,Auto"), ColumnSpacing = 8, - Children = { _prefix, _textBox, _suffix } + Children = { _prefix, _textBox, _suffix, _reveal } }; Height = 40; Padding = new Thickness(12, 0); @@ -225,6 +242,58 @@ private void UpdateSlots() return row; } + // A ghost icon button, h-7 w-7, that pulls into the padding a little. + private (Border, Icon) CreateRevealButton() + { + var icon = new Icon { Kind = IconName.Eye, Size = 16, Token = ShellToken.MutedForeground }; + var button = new Border + { + Child = icon, + Width = 28, + Height = 28, + CornerRadius = new CornerRadius(ShellTheme.RadiusSm), + Background = Brushes.Transparent, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(-4, 0, -6, 0), + Cursor = new Cursor(StandardCursorType.Hand), + IsVisible = false + }; + AutomationProperties.SetName(button, "Show password"); + button.PointerEntered += (_, _) => button.Token(BackgroundProperty, ShellToken.Accent); + button.PointerExited += (_, _) => { button.ClearToken(BackgroundProperty); button.Background = Brushes.Transparent; }; + // Handled, so the press neither focuses the button nor leaves the text box. + button.PointerPressed += (_, e) => + { + ToggleReveal(); + _textBox.Focus(); + e.Handled = true; + }; + ShellFocus.Ring(button, button); + button.KeyDown += (_, e) => + { + if (e.Key is not (Key.Enter or Key.Space)) return; + ToggleReveal(); + e.Handled = true; + }; + return (button, icon); + } + + private void ToggleReveal() + { + _revealed = !_revealed; + UpdatePassword(); + } + + private void UpdatePassword() + { + var password = Variant == InputVariant.Password; + if (!password) _revealed = false; + _textBox.PasswordChar = (IsPassword || password) && !_revealed ? '•' : default; + _reveal.IsVisible = password; + _revealIcon.Kind = _revealed ? IconName.EyeOff : IconName.Eye; + AutomationProperties.SetName(_reveal, _revealed ? "Hide password" : "Show password"); + } + private void UpdateVisualState() { var stroke = HasError ? ShellToken.Destructive : _isFocused ? ShellToken.Ring : ShellToken.Input; diff --git a/examples/Avalonia.Demo/Components/UI/SheetDescription.cs b/examples/Avalonia.Demo/Components/UI/SheetDescription.cs new file mode 100644 index 0000000..4a96f55 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/SheetDescription.cs @@ -0,0 +1,15 @@ +using Avalonia.Controls; +using Avalonia.Media; + +namespace AvaloniaDemo.Components.UI; + +// text-sm text-muted-foreground +public class SheetDescription : TextBlock +{ + public SheetDescription() + { + FontSize = 14; + TextWrapping = TextWrapping.Wrap; + this.Token(ForegroundProperty, ShellToken.MutedForeground); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/SheetFooter.cs b/examples/Avalonia.Demo/Components/UI/SheetFooter.cs new file mode 100644 index 0000000..c71c345 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/SheetFooter.cs @@ -0,0 +1,17 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; + +namespace AvaloniaDemo.Components.UI; + +// Actions row at the end of a sheet — flex justify-end gap-2. +public class SheetFooter : StackPanel +{ + public SheetFooter() + { + Orientation = Orientation.Horizontal; + Spacing = 8; + HorizontalAlignment = HorizontalAlignment.Right; + Margin = new Thickness(0, 8, 0, 0); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/SheetHeader.cs b/examples/Avalonia.Demo/Components/UI/SheetHeader.cs new file mode 100644 index 0000000..c72e449 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/SheetHeader.cs @@ -0,0 +1,20 @@ +using Avalonia.Controls; + +namespace AvaloniaDemo.Components.UI; + +// Title + description at the top of a sheet — flex flex-col space-y-2. +// +// +// +// +// +// ... +// +// +public class SheetHeader : StackPanel +{ + public SheetHeader() + { + Spacing = 8; + } +} diff --git a/examples/Avalonia.Demo/Components/UI/SheetTitle.cs b/examples/Avalonia.Demo/Components/UI/SheetTitle.cs new file mode 100644 index 0000000..e5f0246 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/SheetTitle.cs @@ -0,0 +1,16 @@ +using Avalonia.Controls; +using Avalonia.Media; + +namespace AvaloniaDemo.Components.UI; + +// text-lg font-semibold text-foreground +public class SheetTitle : TextBlock +{ + public SheetTitle() + { + FontSize = 18; + FontWeight = FontWeight.SemiBold; + TextWrapping = TextWrapping.Wrap; + this.Token(ForegroundProperty, ShellToken.Foreground); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/StatCard.cs b/examples/Avalonia.Demo/Components/UI/StatCard.cs index 9bacd99..a878602 100644 --- a/examples/Avalonia.Demo/Components/UI/StatCard.cs +++ b/examples/Avalonia.Demo/Components/UI/StatCard.cs @@ -9,6 +9,8 @@ namespace AvaloniaDemo.Components.UI; // value in text-2xl bold, a change pill tinted by trend, and an optional description. // Usage: +// ValueTone tints the value ("Backup failed" in red); a long value wraps and the pill drops under it. +// IsLoading shows skeletons in place of the value and description; Size="Sm" fits six to a row. public class StatCard : Border { public static readonly StyledProperty TitleProperty = @@ -29,6 +31,15 @@ public class StatCard : Border public static readonly StyledProperty IconProperty = AvaloniaProperty.Register(nameof(Icon)); + public static readonly StyledProperty ValueToneProperty = + AvaloniaProperty.Register(nameof(ValueTone)); + + public static readonly StyledProperty IsLoadingProperty = + AvaloniaProperty.Register(nameof(IsLoading)); + + public static readonly StyledProperty SizeProperty = + AvaloniaProperty.Register(nameof(Size)); + private readonly TextBlock _title; private readonly Icon _icon; private readonly TextBlock _value; @@ -36,6 +47,9 @@ public class StatCard : Border private readonly Border _pillTint; private readonly TextBlock _change; private readonly TextBlock _description; + private readonly WrapPanel _valueRow; + private readonly Skeleton _valueSkeleton; + private readonly Skeleton _descriptionSkeleton; static StatCard() { @@ -45,6 +59,9 @@ static StatCard() TrendProperty.Changed.AddClassHandler((c, _) => c.Update()); DescriptionProperty.Changed.AddClassHandler((c, _) => c.Update()); IconProperty.Changed.AddClassHandler((c, _) => c.Update()); + ValueToneProperty.Changed.AddClassHandler((c, _) => c.Update()); + IsLoadingProperty.Changed.AddClassHandler((c, _) => c.Update()); + SizeProperty.Changed.AddClassHandler((c, _) => c.Update()); } public string? Title @@ -83,6 +100,24 @@ public IconName Icon set => SetValue(IconProperty, value); } + public StatTone ValueTone + { + get => GetValue(ValueToneProperty); + set => SetValue(ValueToneProperty, value); + } + + public bool IsLoading + { + get => GetValue(IsLoadingProperty); + set => SetValue(IsLoadingProperty, value); + } + + public StatCardSize Size + { + get => GetValue(SizeProperty); + set => SetValue(SizeProperty, value); + } + public StatCard() { _title = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; @@ -91,13 +126,16 @@ public StatCard() Grid.SetColumn(_icon, 1); var header = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto"), ColumnSpacing = 8, Children = { _title, _icon } }; - _value = new TextBlock { FontSize = 24, FontWeight = FontWeight.Bold, VerticalAlignment = VerticalAlignment.Center }; - _value.Token(TextBlock.ForegroundProperty, ShellToken.CardForeground); + _value = new TextBlock { FontSize = 24, FontWeight = FontWeight.Bold, VerticalAlignment = VerticalAlignment.Center, TextWrapping = TextWrapping.Wrap }; // The pill is the trend color at low strength behind text in the full color. _pillTint = new Border { CornerRadius = new CornerRadius(ShellTheme.RadiusMd) }; _change = new TextBlock { FontSize = 12, FontWeight = FontWeight.Medium, Margin = new Thickness(6, 2), VerticalAlignment = VerticalAlignment.Center }; - _pill = new Panel { VerticalAlignment = VerticalAlignment.Center, Children = { _pillTint, _change } }; + _pill = new Panel { VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 2), Children = { _pillTint, _change } }; + // The pill wraps under a long value. + _valueRow = new WrapPanel { ItemSpacing = 8, Margin = new Thickness(0, 8, 0, 0), Children = { _value, _pill } }; + _valueSkeleton = new Skeleton { Width = 96, Height = 28, HorizontalAlignment = HorizontalAlignment.Left, Margin = new Thickness(0, 10, 0, 2), IsVisible = false }; + _descriptionSkeleton = new Skeleton { Width = 128, Height = 12, HorizontalAlignment = HorizontalAlignment.Left, Margin = new Thickness(0, 6, 0, 0), IsVisible = false }; _description = new TextBlock { FontSize = 12, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 4, 0, 0) }; _description.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); @@ -107,11 +145,12 @@ public StatCard() Children = { header, - new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Margin = new Thickness(0, 8, 0, 0), Children = { _value, _pill } }, - _description + _valueRow, + _valueSkeleton, + _description, + _descriptionSkeleton } }; - Padding = new Thickness(20); BorderThickness = new Thickness(1); CornerRadius = new CornerRadius(ShellTheme.RadiusLg); this.Token(BackgroundProperty, ShellToken.Card).Token(BorderBrushProperty, ShellToken.Border); @@ -124,6 +163,20 @@ private void Update() _icon.Kind = Icon; _icon.IsVisible = Icon != IconName.None; _value.Text = Value ?? string.Empty; + _value.Token(TextBlock.ForegroundProperty, ValueTone switch + { + StatTone.Success => ShellToken.Success, + StatTone.Warning => ShellToken.Warning, + StatTone.Destructive => ShellToken.Destructive, + _ => ShellToken.CardForeground + }); + + // Sm — p-4, text-xl value, text-xs title. + var small = Size == StatCardSize.Sm; + Padding = small ? new Thickness(16) : new Thickness(20); + _title.FontSize = small ? 12 : 14; + _value.FontSize = small ? 20 : 24; + _valueSkeleton.Height = small ? 24 : 28; _change.Text = Change ?? string.Empty; _pill.IsVisible = !string.IsNullOrEmpty(Change); @@ -138,8 +191,16 @@ private void Update() _change.Token(TextBlock.ForegroundProperty, text); _description.Text = Description ?? string.Empty; - _description.IsVisible = !string.IsNullOrEmpty(Description); + _description.IsVisible = !string.IsNullOrEmpty(Description) && !IsLoading; + + _valueRow.IsVisible = !IsLoading; + _valueSkeleton.IsVisible = IsLoading; + _descriptionSkeleton.IsVisible = IsLoading && !string.IsNullOrEmpty(Description); } } public enum StatTrend { Neutral, Up, Down } + +public enum StatTone { Default, Success, Warning, Destructive } + +public enum StatCardSize { Default, Sm } diff --git a/examples/Avalonia.Demo/Components/UI/Variants/ButtonVariants.cs b/examples/Avalonia.Demo/Components/UI/Variants/ButtonVariants.cs index 4f48e7c..cc7d592 100644 --- a/examples/Avalonia.Demo/Components/UI/Variants/ButtonVariants.cs +++ b/examples/Avalonia.Demo/Components/UI/Variants/ButtonVariants.cs @@ -17,7 +17,9 @@ public enum ButtonSize Default, Sm, Lg, - Icon + Icon, + // No height or padding: a Link button that sits in a line of text or at a card's edge (h-auto p-0). + Inline } // Token-based style for one variant + size. Null background/border = transparent. @@ -94,6 +96,10 @@ public static ButtonStyle GetStyle(ButtonVariant variant = ButtonVariant.Default style.Width = 40; style.Padding = new Thickness(0); break; + case ButtonSize.Inline: // h-auto p-0 + style.Height = double.NaN; + style.Padding = new Thickness(0); + break; } return style; diff --git a/examples/Avalonia.Demo/MainWindow.axaml b/examples/Avalonia.Demo/MainWindow.axaml index d488209..acbf149 100644 --- a/examples/Avalonia.Demo/MainWindow.axaml +++ b/examples/Avalonia.Demo/MainWindow.axaml @@ -52,6 +52,10 @@ + + + + @@ -59,7 +63,7 @@ - + @@ -107,6 +111,18 @@ + + + + + + + + + + + + @@ -173,10 +189,15 @@ - - + + + + + + + @@ -478,6 +499,16 @@ + + + + + + + + + diff --git a/examples/Avalonia.Demo/MainWindow.axaml.cs b/examples/Avalonia.Demo/MainWindow.axaml.cs index 31f27c5..e0b4867 100644 --- a/examples/Avalonia.Demo/MainWindow.axaml.cs +++ b/examples/Avalonia.Demo/MainWindow.axaml.cs @@ -3,6 +3,7 @@ using System.Linq; using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.Documents; using Avalonia.Layout; using AvaloniaDemo.Components.UI; @@ -85,6 +86,18 @@ public MainWindow() DemoCommand.ItemSelected += (_, item) => CommandChoice.Text = $"Command: {item.Value}."; DemoPalette.ItemSelected += (_, item) => CommandChoice.Text = $"Palette: {item.Value}."; OpenPalette.Clicked += (_, _) => DemoPalette.SetOpen(true); + // The status column draws a badge; sorting and the filter still use Property. + DemoDataTable.Columns[2].Cell = item => new Badge + { + Text = ((DemoInvoice)item).Status, + Variant = ((DemoInvoice)item).Status switch { "Paid" => BadgeVariant.Success, "Pending" => BadgeVariant.Warning, _ => BadgeVariant.Destructive }, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center + }; + // Runs take theme tokens like any element, so they follow the theme when it changes. + RichText.Inlines!.Add(new Run("Order #1042 is on hold: card declined").Token(Run.ForegroundProperty, ShellToken.Foreground)); + RichText.Inlines.Add(new Run(" · 2 min ago").Token(Run.ForegroundProperty, ShellToken.MutedForeground)); + SheetSave.Clicked += (_, _) => DemoSheet.SetOpen(false); DemoDataTable.ItemsSource = new List { new DemoInvoice("INV001", "Shewart Shepherd", "Paid", 250.00m, new DateTime(2026, 9, 2)), diff --git a/examples/MAUI.Demo/Components/UI/CardHeader.cs b/examples/MAUI.Demo/Components/UI/CardHeader.cs index 3927c51..82a85df 100644 --- a/examples/MAUI.Demo/Components/UI/CardHeader.cs +++ b/examples/MAUI.Demo/Components/UI/CardHeader.cs @@ -1,6 +1,15 @@ namespace MAUI.Demo.Components.UI; // Card header — p-6 space-y-1.5; title font-semibold, description text-sm text-muted-foreground. +// Views inside sit on the title's line (badges, a count) and wrap under it when there is no room; +// Action sits at the top right (shadcn's CardAction). +// +// +// +// +// +// +[ContentProperty(nameof(Children))] public partial class CardHeader : ContentView { public static readonly BindableProperty TitleProperty = @@ -11,8 +20,14 @@ public partial class CardHeader : ContentView BindableProperty.Create(nameof(Description), typeof(string), typeof(CardHeader), string.Empty, propertyChanged: (b, o, n) => (b as CardHeader)?.UpdateText()); + public static readonly BindableProperty ActionProperty = + BindableProperty.Create(nameof(Action), typeof(View), typeof(CardHeader), null, + propertyChanged: (b, o, n) => ((CardHeader)b).UpdateAction()); + private readonly Label _title; private readonly Label _description; + private readonly FlexLayout _titleLine; + private readonly ContentView _action; public string Title { @@ -26,25 +41,79 @@ public string Description set => SetValue(DescriptionProperty, value); } + // Any view at the top right: a button, a menu, a select. + public View? Action + { + get => (View?)GetValue(ActionProperty); + set => SetValue(ActionProperty, value); + } + + // Views after the title, on its line. + public new IList Children { get; } + public CardHeader() { - _title = new Label { FontSize = 18, FontAttributes = FontAttributes.Bold }; + _title = new Label { FontSize = 18, FontAttributes = FontAttributes.Bold, IsVisible = false, Margin = new Thickness(0, 0, 8, 0) }; _title.Token(Label.TextColorProperty, ShellToken.CardForeground); _description = new Label { FontSize = 14, IsVisible = false }; _description.Token(Label.TextColorProperty, ShellToken.MutedForeground); - Content = new VerticalStackLayout + // FlexLayout has no gap: the title and each item get 8 on their right. + _titleLine = new FlexLayout { Wrap = Microsoft.Maui.Layouts.FlexWrap.Wrap, AlignItems = Microsoft.Maui.Layouts.FlexAlignItems.Center, Children = { _title } }; + Children = new TitleLine(_titleLine); + _action = new ContentView { VerticalOptions = LayoutOptions.Start, IsVisible = false }; + + var grid = new Grid { - Spacing = 6, Padding = new Thickness(24, 24, 24, 16), - Children = { _title, _description } + ColumnSpacing = 16, + ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto) } }; + grid.Add(new VerticalStackLayout { Spacing = 6, Children = { _titleLine, _description } }, 0, 0); + grid.Add(_action, 1, 0); + Content = grid; } private void UpdateText() { _title.Text = Title ?? string.Empty; + _title.IsVisible = !string.IsNullOrEmpty(Title); _description.Text = Description ?? string.Empty; _description.IsVisible = !string.IsNullOrEmpty(Description); } + + private void UpdateAction() + { + _action.Content = Action; + _action.IsVisible = Action != null; + } + + // The title line's items after the title label, spaced like gap-2. + private sealed class TitleLine(FlexLayout line) : System.Collections.ObjectModel.Collection + { + protected override void InsertItem(int index, IView item) + { + base.InsertItem(index, item); + if (item is View view && view.Margin == default) view.Margin = new Thickness(0, 2, 8, 2); + line.Children.Insert(index + 1, item); + } + + protected override void RemoveItem(int index) + { + base.RemoveItem(index); + line.Children.RemoveAt(index + 1); + } + + protected override void SetItem(int index, IView item) + { + base.SetItem(index, item); + line.Children[index + 1] = item; + } + + protected override void ClearItems() + { + base.ClearItems(); + while (line.Children.Count > 1) line.Children.RemoveAt(1); + } + } } diff --git a/examples/MAUI.Demo/Components/UI/DataTable.cs b/examples/MAUI.Demo/Components/UI/DataTable.cs index 9f6b20b..009cd5e 100644 --- a/examples/MAUI.Demo/Components/UI/DataTable.cs +++ b/examples/MAUI.Demo/Components/UI/DataTable.cs @@ -16,6 +16,8 @@ namespace MAUI.Demo.Components.UI; // // A column reads its Property from each item by name. With trimming or NativeAOT, set the // column's Value in code instead: column.Value = item => ((Invoice)item).Number; +// For a view in a cell (a badge, a link), set Cell in code: +// column.Cell = item => new Badge { Text = ((Invoice)item).Status }; (before ItemsSource) [ContentProperty(nameof(Columns))] public partial class DataTable : ContentView { @@ -290,7 +292,9 @@ private void Render(IReadOnlyList rows) } _rowViews.Add((item, row, check)); foreach (var column in _columns) - row.Children.Add(new TableCell { Text = column.Text(item), IsBold = column.IsBold, HorizontalTextAlignment = column.HorizontalTextAlignment }); + row.Children.Add(column.Cell is { } cell + ? new TableCell { Content = cell(item) } + : new TableCell { Text = column.Text(item), IsBold = column.IsBold, HorizontalTextAlignment = column.HorizontalTextAlignment }); _table.Rows.Add(row); } @@ -415,6 +419,11 @@ public partial class DataTableColumn // Reads the column's value from an item; replaces Property. public Func? Value { get; set; } + // Draws the cell as any view (a link, a badge, an amount); replaces the text. Filter and sort + // still use Value or Property, so set one of them for a column that sorts or is searched. Set it + // before ItemsSource, or call Refresh() after. + public Func? Cell { get; set; } + // A .NET format string for the value, e.g. "C" or "MMM d, yyyy". public string? Format { get; set; } diff --git a/examples/MAUI.Demo/Components/UI/Icon.cs b/examples/MAUI.Demo/Components/UI/Icon.cs index 1931402..bbb1f80 100644 --- a/examples/MAUI.Demo/Components/UI/Icon.cs +++ b/examples/MAUI.Demo/Components/UI/Icon.cs @@ -5,7 +5,7 @@ namespace MAUI.Demo.Components.UI; // Stroke icons from ShellIcons (https://github.com/shellui-dev/shell-icons), derived from // Lucide 0.475.0 (ISC license) — the icon set ShellUI uses. Drawn with MAUI shapes: no font or // package, crisp at any size, theme-aware. Generated by scripts/generate-icons.py; a curated set -// of 110 icons — add names there and re-run to include more. +// of 114 icons — add names there and re-run to include more. // Usage: Tint with Token="MutedForeground" or Color="Red". public enum IconName { @@ -76,6 +76,7 @@ public enum IconName Lock, LockOpen, Key, + KeyRound, Shield, Eye, EyeOff, @@ -85,12 +86,15 @@ public enum IconName MessageCircle, Phone, Calendar, + CalendarClock, Clock, Globe, MapPin, Image, File, FileText, + Receipt, + ListChecks, Folder, Tag, Bookmark, @@ -193,6 +197,7 @@ public static class IconPaths IconName.Lock => "M 5 11 L 19 11 C 20.105 11 21 11.895 21 13 L 21 20 C 21 21.105 20.105 22 19 22 L 5 22 C 3.895 22 3 21.105 3 20 L 3 13 C 3 11.895 3.895 11 5 11 Z M 7 11 L 7 7 C 7 4.239 9.239 2 12 2 C 14.761 2 17 4.239 17 7 L 17 11", IconName.LockOpen => "M 5 11 L 19 11 C 20.105 11 21 11.895 21 13 L 21 20 C 21 21.105 20.105 22 19 22 L 5 22 C 3.895 22 3 21.105 3 20 L 3 13 C 3 11.895 3.895 11 5 11 Z M 7 11 L 7 7 C 6.997 4.431 8.942 2.278 11.498 2.02 C 14.053 1.762 16.389 3.483 16.9 6", IconName.Key => "M 15.5 7.5 L 17.8 9.8 C 18.189 10.181 18.811 10.181 19.2 9.8 L 21.3 7.7 C 21.681 7.311 21.681 6.689 21.3 6.3 L 19 4 M 21 2 L 11.4 11.6 M 13 15.5 C 13 18.538 10.538 21 7.5 21 C 4.462 21 2 18.538 2 15.5 C 2 12.462 4.462 10 7.5 10 C 10.538 10 13 12.462 13 15.5 Z", + IconName.KeyRound => "M 2.586 17.414 C 2.211 17.789 2 18.298 2 18.828 L 2 21 C 2 21.552 2.448 22 3 22 L 6 22 C 6.552 22 7 21.552 7 21 L 7 20 C 7 19.448 7.448 19 8 19 L 9 19 C 9.552 19 10 18.552 10 18 L 10 17 C 10 16.448 10.448 16 11 16 L 11.172 16 C 11.702 16 12.211 15.789 12.586 15.414 L 13.4 14.6 C 16.262 15.597 19.434 14.484 21.044 11.916 C 22.655 9.349 22.278 6.009 20.134 3.866 C 17.991 1.722 14.651 1.345 12.084 2.956 C 9.516 4.566 8.403 7.738 9.4 10.6 Z M 17 7.5 C 17 7.776 16.776 8 16.5 8 C 16.224 8 16 7.776 16 7.5 C 16 7.224 16.224 7 16.5 7 C 16.776 7 17 7.224 17 7.5 Z", IconName.Shield => "M 20 13 C 20 18 16.5 20.5 12.34 21.95 C 12.122 22.024 11.886 22.02 11.67 21.94 C 7.5 20.5 4 18 4 13 L 4 6 C 4 5.448 4.448 5 5 5 C 7 5 9.5 3.8 11.24 2.28 C 11.678 1.906 12.322 1.906 12.76 2.28 C 14.51 3.81 17 5 19 5 C 19.552 5 20 5.448 20 6 Z", IconName.Eye => "M 2.062 12.348 C 1.979 12.123 1.979 11.877 2.062 11.652 C 3.722 7.627 7.646 5.001 12 5.001 C 16.354 5.001 20.278 7.627 21.938 11.652 C 22.021 11.877 22.021 12.123 21.938 12.348 C 20.278 16.373 16.354 18.999 12 18.999 C 7.646 18.999 3.722 16.373 2.062 12.348 M 15 12 C 15 13.657 13.657 15 12 15 C 10.343 15 9 13.657 9 12 C 9 10.343 10.343 9 12 9 C 13.657 9 15 10.343 15 12 Z", IconName.EyeOff => "M 10.733 5.076 C 15.52 4.506 20.101 7.194 21.938 11.651 C 22.021 11.876 22.021 12.122 21.938 12.347 C 21.57 13.238 21.085 14.076 20.494 14.837 M 14.084 14.158 C 12.907 15.295 11.036 15.279 9.879 14.121 C 8.721 12.964 8.705 11.093 9.842 9.916 M 17.479 17.499 C 14.795 19.089 11.553 19.434 8.594 18.446 C 5.635 17.457 3.251 15.232 2.062 12.348 C 1.979 12.123 1.979 11.877 2.062 11.652 C 2.949 9.502 4.509 7.697 6.508 6.509 M 2 2 L 22 22", @@ -202,12 +207,15 @@ public static class IconPaths IconName.MessageCircle => "M 7.9 20 C 11.843 22.023 16.672 20.874 19.282 17.292 C 21.892 13.71 21.505 8.762 18.372 5.628 C 15.238 2.495 10.29 2.108 6.708 4.718 C 3.126 7.328 1.977 12.157 4 16.1 L 2 22 Z", IconName.Phone => "M 22 16.92 L 22 19.92 C 22.002 20.483 21.767 21.021 21.352 21.402 C 20.937 21.783 20.381 21.971 19.82 21.92 C 16.743 21.586 13.787 20.534 11.19 18.85 C 8.774 17.315 6.725 15.266 5.19 12.85 C 3.5 10.241 2.448 7.271 2.12 4.18 C 2.069 3.621 2.256 3.066 2.635 2.652 C 3.013 2.237 3.549 2.001 4.11 2 L 7.11 2 C 8.114 1.99 8.97 2.726 9.11 3.72 C 9.237 4.68 9.471 5.623 9.81 6.53 C 10.085 7.261 9.909 8.085 9.36 8.64 L 8.09 9.91 C 9.514 12.414 11.586 14.486 14.09 15.91 L 15.36 14.64 C 15.915 14.091 16.739 13.915 17.47 14.19 C 18.377 14.529 19.32 14.763 20.28 14.89 C 21.286 15.032 22.025 15.905 22 16.92 Z", IconName.Calendar => "M 8 2 L 8 6 M 16 2 L 16 6 M 5 4 L 19 4 C 20.105 4 21 4.895 21 6 L 21 20 C 21 21.105 20.105 22 19 22 L 5 22 C 3.895 22 3 21.105 3 20 L 3 6 C 3 4.895 3.895 4 5 4 Z M 3 10 L 21 10", + IconName.CalendarClock => "M 21 7.5 L 21 6 C 21 4.895 20.105 4 19 4 L 5 4 C 3.895 4 3 4.895 3 6 L 3 20 C 3 21.105 3.895 22 5 22 L 8.5 22 M 16 2 L 16 6 M 8 2 L 8 6 M 3 10 L 8 10 M 17.5 17.5 L 16 16.3 L 16 14 M 22 16 C 22 19.314 19.314 22 16 22 C 12.686 22 10 19.314 10 16 C 10 12.686 12.686 10 16 10 C 19.314 10 22 12.686 22 16 Z", IconName.Clock => "M 22 12 C 22 17.523 17.523 22 12 22 C 6.477 22 2 17.523 2 12 C 2 6.477 6.477 2 12 2 C 17.523 2 22 6.477 22 12 Z M 12 6 L 12 12 L 16 14", IconName.Globe => "M 22 12 C 22 17.523 17.523 22 12 22 C 6.477 22 2 17.523 2 12 C 2 6.477 6.477 2 12 2 C 17.523 2 22 6.477 22 12 Z M 12 2 C 6.667 7.6 6.667 16.4 12 22 C 17.333 16.4 17.333 7.6 12 2 M 2 12 L 22 12", IconName.MapPin => "M 20 10 C 20 14.993 14.461 20.193 12.601 21.799 C 12.245 22.067 11.755 22.067 11.399 21.799 C 9.539 20.193 4 14.993 4 10 C 4 5.582 7.582 2 12 2 C 16.418 2 20 5.582 20 10 M 15 10 C 15 11.657 13.657 13 12 13 C 10.343 13 9 11.657 9 10 C 9 8.343 10.343 7 12 7 C 13.657 7 15 8.343 15 10 Z", IconName.Image => "M 5 3 L 19 3 C 20.105 3 21 3.895 21 5 L 21 19 C 21 20.105 20.105 21 19 21 L 5 21 C 3.895 21 3 20.105 3 19 L 3 5 C 3 3.895 3.895 3 5 3 Z M 11 9 C 11 10.105 10.105 11 9 11 C 7.895 11 7 10.105 7 9 C 7 7.895 7.895 7 9 7 C 10.105 7 11 7.895 11 9 Z M 21 15 L 17.914 11.914 C 17.133 11.133 15.867 11.133 15.086 11.914 L 6 21", IconName.File => "M 15 2 L 6 2 C 4.895 2 4 2.895 4 4 L 4 20 C 4 21.105 4.895 22 6 22 L 18 22 C 19.105 22 20 21.105 20 20 L 20 7 Z M 14 2 L 14 6 C 14 7.105 14.895 8 16 8 L 20 8", IconName.FileText => "M 15 2 L 6 2 C 4.895 2 4 2.895 4 4 L 4 20 C 4 21.105 4.895 22 6 22 L 18 22 C 19.105 22 20 21.105 20 20 L 20 7 Z M 14 2 L 14 6 C 14 7.105 14.895 8 16 8 L 20 8 M 10 9 L 8 9 M 16 13 L 8 13 M 16 17 L 8 17", + IconName.Receipt => "M 4 2 L 4 22 L 6 21 L 8 22 L 10 21 L 12 22 L 14 21 L 16 22 L 18 21 L 20 22 L 20 2 L 18 3 L 16 2 L 14 3 L 12 2 L 10 3 L 8 2 L 6 3 Z M 16 8 L 10 8 C 8.895 8 8 8.895 8 10 C 8 11.105 8.895 12 10 12 L 14 12 C 15.105 12 16 12.895 16 14 C 16 15.105 15.105 16 14 16 L 8 16 M 12 17.5 L 12 6.5", + IconName.ListChecks => "M 3 17 L 5 19 L 9 15 M 3 7 L 5 9 L 9 5 M 13 6 L 21 6 M 13 12 L 21 12 M 13 18 L 21 18", IconName.Folder => "M 20 20 C 21.105 20 22 19.105 22 18 L 22 8 C 22 6.895 21.105 6 20 6 L 12.1 6 C 11.42 6.007 10.784 5.668 10.41 5.1 L 9.6 3.9 C 9.23 3.338 8.603 3 7.93 3 L 4 3 C 2.895 3 2 3.895 2 5 L 2 18 C 2 19.105 2.895 20 4 20 Z", IconName.Tag => "M 12.586 2.586 C 12.211 2.211 11.702 2 11.172 2 L 4 2 C 2.895 2 2 2.895 2 4 L 2 11.172 C 2 11.702 2.211 12.211 2.586 12.586 L 11.29 21.29 C 12.236 22.23 13.764 22.23 14.71 21.29 L 21.29 14.71 C 22.23 13.764 22.23 12.236 21.29 11.29 Z M 8 7.5 C 8 7.776 7.776 8 7.5 8 C 7.224 8 7 7.776 7 7.5 C 7 7.224 7.224 7 7.5 7 C 7.776 7 8 7.224 8 7.5 Z", IconName.Bookmark => "M 19 21 L 12 17 L 5 21 L 5 5 C 5 3.895 5.895 3 7 3 L 17 3 C 18.105 3 19 3.895 19 5 L 19 21 Z", diff --git a/examples/MAUI.Demo/Components/UI/Input.cs b/examples/MAUI.Demo/Components/UI/Input.cs index 9ac84d5..be1f137 100644 --- a/examples/MAUI.Demo/Components/UI/Input.cs +++ b/examples/MAUI.Demo/Components/UI/Input.cs @@ -5,6 +5,7 @@ namespace MAUI.Demo.Components.UI; // Text input — h-10 rounded-md border border-input px-3 text-sm. // Focus: border-ring + soft ring glow. Error: border-destructive. // The native Entry's own frame is stripped so this Border is the only chrome. +// Variant="Password" masks the text and adds an eye button that shows it; IsPassword only masks. public partial class Input : ContentView { public static readonly BindableProperty TextProperty = @@ -17,7 +18,11 @@ public partial class Input : ContentView public static readonly BindableProperty IsPasswordProperty = BindableProperty.Create(nameof(IsPassword), typeof(bool), typeof(Input), - false, propertyChanged: (b, o, n) => ((Input)b)._entry.IsPassword = (bool)n); + false, propertyChanged: (b, o, n) => ((Input)b).UpdatePassword()); + + public static readonly BindableProperty VariantProperty = + BindableProperty.Create(nameof(Variant), typeof(InputVariant), typeof(Input), InputVariant.Default, + propertyChanged: (b, o, n) => ((Input)b).UpdatePassword()); public static readonly BindableProperty HasErrorProperty = BindableProperty.Create(nameof(HasError), typeof(bool), typeof(Input), @@ -37,7 +42,10 @@ public partial class Input : ContentView private readonly Entry _entry; private readonly Border _border; + private readonly Border _reveal; + private readonly Icon _revealIcon; private bool _isFocused; + private bool _revealed; public string Text { @@ -57,6 +65,12 @@ public bool IsPassword set => SetValue(IsPasswordProperty, value); } + public InputVariant Variant + { + get => (InputVariant)GetValue(VariantProperty); + set => SetValue(VariantProperty, value); + } + public bool HasError { get => (bool)GetValue(HasErrorProperty); @@ -108,9 +122,17 @@ public Input() _entry.Focused += (s, e) => { _isFocused = true; UpdateVisualState(); }; _entry.Unfocused += (s, e) => { _isFocused = false; UpdateVisualState(); }; + (_reveal, _revealIcon) = CreateRevealButton(); + var row = new Grid + { + ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto) } + }; + row.Add(_entry, 0, 0); + row.Add(_reveal, 1, 0); + _border = new Border { - Content = _entry, + Content = row, Padding = new Thickness(12, 0), HeightRequest = 40, StrokeThickness = 1, @@ -127,6 +149,50 @@ public Input() UpdateVisualState(); } + // A ghost icon button, h-7 w-7, that pulls into the padding a little. + private (Border, Icon) CreateRevealButton() + { + var icon = new Icon { Name = IconName.Eye, Size = 16, Token = ShellToken.MutedForeground }; + var button = new Border + { + Content = icon, + WidthRequest = 28, + HeightRequest = 28, + StrokeThickness = 0, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusSm }, + BackgroundColor = Colors.Transparent, + VerticalOptions = LayoutOptions.Center, + Margin = new Thickness(4, 0, -6, 0), + IsVisible = false + }; + SemanticProperties.SetDescription(button, "Show password"); + var pointer = new PointerGestureRecognizer(); + pointer.PointerEntered += (_, _) => button.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent); + pointer.PointerExited += (_, _) => { button.ClearValue(VisualElement.BackgroundColorProperty); button.BackgroundColor = Colors.Transparent; }; + button.GestureRecognizers.Add(pointer); + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => ToggleReveal(); + button.GestureRecognizers.Add(tap); + ShellFocus.MakeFocusable(button, ToggleReveal); + return (button, icon); + } + + private void ToggleReveal() + { + _revealed = !_revealed; + UpdatePassword(); + } + + private void UpdatePassword() + { + var password = Variant == InputVariant.Password; + if (!password) _revealed = false; + _entry.IsPassword = (IsPassword || password) && !_revealed; + _reveal.IsVisible = password; + _revealIcon.Name = _revealed ? IconName.EyeOff : IconName.Eye; + SemanticProperties.SetDescription(_reveal, _revealed ? "Hide password" : "Show password"); + } + private static void OnTextChanged(BindableObject bindable, object oldValue, object newValue) { if (bindable is Input input && input._entry.Text != newValue as string) @@ -158,3 +224,6 @@ private void UpdateVisualState() public new void Focus() => _entry.Focus(); public new void Unfocus() => _entry.Unfocus(); } + +// Password masks the text and shows an eye button that reveals it. +public enum InputVariant { Default, Password } diff --git a/examples/MAUI.Demo/Components/UI/InputGroup.cs b/examples/MAUI.Demo/Components/UI/InputGroup.cs index 2ef7fc1..4f9c97f 100644 --- a/examples/MAUI.Demo/Components/UI/InputGroup.cs +++ b/examples/MAUI.Demo/Components/UI/InputGroup.cs @@ -9,6 +9,7 @@ namespace MAUI.Demo.Components.UI; // // ...any view... // +// Variant="Password" masks the text and adds an eye button after the suffix that shows it; IsPassword only masks. public partial class InputGroup : ContentView { public static readonly BindableProperty TextProperty = @@ -25,7 +26,11 @@ public partial class InputGroup : ContentView public static readonly BindableProperty IsPasswordProperty = BindableProperty.Create(nameof(IsPassword), typeof(bool), typeof(InputGroup), false, - propertyChanged: (b, o, n) => ((InputGroup)b)._entry.IsPassword = (bool)n); + propertyChanged: (b, o, n) => ((InputGroup)b).UpdatePassword()); + + public static readonly BindableProperty VariantProperty = + BindableProperty.Create(nameof(Variant), typeof(InputVariant), typeof(InputGroup), InputVariant.Default, + propertyChanged: (b, o, n) => ((InputGroup)b).UpdatePassword()); public static readonly BindableProperty HasErrorProperty = BindableProperty.Create(nameof(HasError), typeof(bool), typeof(InputGroup), false, @@ -79,6 +84,12 @@ public bool IsPassword set => SetValue(IsPasswordProperty, value); } + public InputVariant Variant + { + get => (InputVariant)GetValue(VariantProperty); + set => SetValue(VariantProperty, value); + } + public bool HasError { get => (bool)GetValue(HasErrorProperty); @@ -134,7 +145,10 @@ public View? Suffix private readonly Border _border; private readonly ContentView _prefix; private readonly ContentView _suffix; + private readonly Border _reveal; + private readonly Icon _revealIcon; private bool _isFocused; + private bool _revealed; public InputGroup() { @@ -167,12 +181,15 @@ public InputGroup() ColumnSpacing = 8, ColumnDefinitions = { - new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto) + new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto), + new ColumnDefinition(GridLength.Auto) } }; + (_reveal, _revealIcon) = CreateRevealButton(); row.Add(_prefix, 0, 0); row.Add(_entry, 1, 0); row.Add(_suffix, 2, 0); + row.Add(_reveal, 3, 0); _border = new Border { @@ -202,6 +219,50 @@ protected override void OnPropertyChanged(string? propertyName = null) Opacity = IsEnabled ? 1.0 : 0.5; } + // A ghost icon button, h-7 w-7, that pulls into the padding a little. + private (Border, Icon) CreateRevealButton() + { + var icon = new Icon { Name = IconName.Eye, Size = 16, Token = ShellToken.MutedForeground }; + var button = new Border + { + Content = icon, + WidthRequest = 28, + HeightRequest = 28, + StrokeThickness = 0, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusSm }, + BackgroundColor = Colors.Transparent, + VerticalOptions = LayoutOptions.Center, + Margin = new Thickness(-4, 0, -6, 0), + IsVisible = false + }; + SemanticProperties.SetDescription(button, "Show password"); + var pointer = new PointerGestureRecognizer(); + pointer.PointerEntered += (_, _) => button.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent); + pointer.PointerExited += (_, _) => { button.ClearValue(VisualElement.BackgroundColorProperty); button.BackgroundColor = Colors.Transparent; }; + button.GestureRecognizers.Add(pointer); + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => ToggleReveal(); + button.GestureRecognizers.Add(tap); + ShellFocus.MakeFocusable(button, ToggleReveal); + return (button, icon); + } + + private void ToggleReveal() + { + _revealed = !_revealed; + UpdatePassword(); + } + + private void UpdatePassword() + { + var password = Variant == InputVariant.Password; + if (!password) _revealed = false; + _entry.IsPassword = (IsPassword || password) && !_revealed; + _reveal.IsVisible = password; + _revealIcon.Name = _revealed ? IconName.EyeOff : IconName.Eye; + SemanticProperties.SetDescription(_reveal, _revealed ? "Hide password" : "Show password"); + } + private void UpdateSlots() { _prefix.Content = Prefix ?? Slot(PrefixIcon, PrefixText); diff --git a/examples/MAUI.Demo/Components/UI/SheetDescription.cs b/examples/MAUI.Demo/Components/UI/SheetDescription.cs new file mode 100644 index 0000000..5d16925 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/SheetDescription.cs @@ -0,0 +1,11 @@ +namespace MAUI.Demo.Components.UI; + +// text-sm text-muted-foreground +public partial class SheetDescription : Label +{ + public SheetDescription() + { + FontSize = 14; + this.Token(TextColorProperty, ShellToken.MutedForeground); + } +} diff --git a/examples/MAUI.Demo/Components/UI/SheetFooter.cs b/examples/MAUI.Demo/Components/UI/SheetFooter.cs new file mode 100644 index 0000000..b0e4892 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/SheetFooter.cs @@ -0,0 +1,21 @@ +namespace MAUI.Demo.Components.UI; + +// Actions row at the end of a sheet — flex justify-end gap-2. +[ContentProperty(nameof(Children))] +public partial class SheetFooter : ContentView +{ + private readonly HorizontalStackLayout _stack; + + public new IList Children => _stack.Children; + + public SheetFooter() + { + _stack = new HorizontalStackLayout + { + Spacing = 8, + HorizontalOptions = LayoutOptions.End, + Margin = new Thickness(0, 8, 0, 0) + }; + Content = _stack; + } +} diff --git a/examples/MAUI.Demo/Components/UI/SheetHeader.cs b/examples/MAUI.Demo/Components/UI/SheetHeader.cs new file mode 100644 index 0000000..7f6f446 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/SheetHeader.cs @@ -0,0 +1,24 @@ +namespace MAUI.Demo.Components.UI; + +// Title + description at the top of a sheet — flex flex-col space-y-2. +// +// +// +// +// +// ... +// +// +[ContentProperty(nameof(Children))] +public partial class SheetHeader : ContentView +{ + private readonly VerticalStackLayout _stack; + + public new IList Children => _stack.Children; + + public SheetHeader() + { + _stack = new VerticalStackLayout { Spacing = 8 }; + Content = _stack; + } +} diff --git a/examples/MAUI.Demo/Components/UI/SheetTitle.cs b/examples/MAUI.Demo/Components/UI/SheetTitle.cs new file mode 100644 index 0000000..f2268e8 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/SheetTitle.cs @@ -0,0 +1,12 @@ +namespace MAUI.Demo.Components.UI; + +// text-lg font-semibold text-foreground +public partial class SheetTitle : Label +{ + public SheetTitle() + { + FontSize = 18; + FontAttributes = FontAttributes.Bold; + this.Token(TextColorProperty, ShellToken.Foreground); + } +} diff --git a/examples/MAUI.Demo/Components/UI/Shell.cs b/examples/MAUI.Demo/Components/UI/Shell.cs index 5c72397..3e89d16 100644 --- a/examples/MAUI.Demo/Components/UI/Shell.cs +++ b/examples/MAUI.Demo/Components/UI/Shell.cs @@ -141,8 +141,16 @@ public static void EnsureInitialized() if (app is null) return; _initialized = true; app.RequestedThemeChanged += (_, _) => Apply(); - // Set up each page's overlay layer as it appears. Doing it on first use instead would - // re-parent the page content under the user's finger (Android drops the scroll position). + // Set up each page's overlay layer as the page joins the app, before its views load, so + // wrapping the content doesn't unload and load them again (a view that cancels its work + // on Unloaded would lose its first load). + app.DescendantAdded += (_, e) => + { + if (e.Element is ContentPage { Content: not null } page) ShellPortal.GetLayer(page); + }; + // A page whose content is set later gets its layer as it appears. Doing it on first use + // instead would re-parent the page content under the user's finger (Android drops the + // scroll position). // Android starts a new activity, with unthemed bars, when the app is reopened after Back. app.PageAppearing += (_, page) => page.Dispatcher.Dispatch(() => { diff --git a/examples/MAUI.Demo/Components/UI/StatCard.cs b/examples/MAUI.Demo/Components/UI/StatCard.cs index 4506dca..934f1fd 100644 --- a/examples/MAUI.Demo/Components/UI/StatCard.cs +++ b/examples/MAUI.Demo/Components/UI/StatCard.cs @@ -6,6 +6,8 @@ namespace MAUI.Demo.Components.UI; // value in text-2xl bold, a change pill tinted by trend, and an optional description. // Usage: +// ValueTone tints the value ("Backup failed" in red); a long value wraps and the pill drops under it. +// IsLoading shows skeletons in place of the value and description; Size="Sm" fits six to a row. public partial class StatCard : ContentView { public static readonly BindableProperty TitleProperty = @@ -32,6 +34,18 @@ public partial class StatCard : ContentView BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(StatCard), IconName.None, propertyChanged: (b, o, n) => ((StatCard)b).Update()); + public static readonly BindableProperty ValueToneProperty = + BindableProperty.Create(nameof(ValueTone), typeof(StatTone), typeof(StatCard), StatTone.Default, + propertyChanged: (b, o, n) => ((StatCard)b).Update()); + + public static readonly BindableProperty IsLoadingProperty = + BindableProperty.Create(nameof(IsLoading), typeof(bool), typeof(StatCard), false, + propertyChanged: (b, o, n) => ((StatCard)b).Update()); + + public static readonly BindableProperty SizeProperty = + BindableProperty.Create(nameof(Size), typeof(StatCardSize), typeof(StatCard), StatCardSize.Default, + propertyChanged: (b, o, n) => ((StatCard)b).Update()); + public string Title { get => (string)GetValue(TitleProperty); @@ -68,6 +82,28 @@ public IconName Icon set => SetValue(IconProperty, value); } + public StatTone ValueTone + { + get => (StatTone)GetValue(ValueToneProperty); + set => SetValue(ValueToneProperty, value); + } + + public bool IsLoading + { + get => (bool)GetValue(IsLoadingProperty); + set => SetValue(IsLoadingProperty, value); + } + + public StatCardSize Size + { + get => (StatCardSize)GetValue(SizeProperty); + set => SetValue(SizeProperty, value); + } + + private readonly Border _card; + private readonly FlexLayout _valueRow; + private readonly Skeleton _valueSkeleton; + private readonly Skeleton _descriptionSkeleton; private readonly Label _title; private readonly Icon _icon; private readonly Label _value; @@ -89,8 +125,8 @@ public StatCard() header.Add(_title, 0, 0); header.Add(_icon, 1, 0); - _value = new Label { FontSize = 24, FontAttributes = FontAttributes.Bold, VerticalTextAlignment = TextAlignment.Center }; - _value.Token(Label.TextColorProperty, ShellToken.CardForeground); + // FlexLayout has no gap: the value keeps 8 on its right, so the pill wraps under a long value. + _value = new Label { FontSize = 24, FontAttributes = FontAttributes.Bold, VerticalTextAlignment = TextAlignment.Center, LineBreakMode = LineBreakMode.WordWrap, Margin = new Thickness(0, 0, 8, 0) }; // The pill is the trend color at low strength behind text in the full color. _pillTint = new BoxView { BackgroundColor = Colors.Transparent }; @@ -101,13 +137,23 @@ public StatCard() StrokeThickness = 0, StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }, VerticalOptions = LayoutOptions.Center, + Margin = new Thickness(0, 2), BackgroundColor = Colors.Transparent }; + _valueRow = new FlexLayout + { + Wrap = Microsoft.Maui.Layouts.FlexWrap.Wrap, + AlignItems = Microsoft.Maui.Layouts.FlexAlignItems.Center, + Margin = new Thickness(0, 8, 0, 0), + Children = { _value, _pill } + }; + _valueSkeleton = new Skeleton { WidthRequest = 96, HeightRequest = 28, HorizontalOptions = LayoutOptions.Start, Margin = new Thickness(0, 10, 0, 2), IsVisible = false }; + _descriptionSkeleton = new Skeleton { WidthRequest = 128, HeightRequest = 12, HorizontalOptions = LayoutOptions.Start, Margin = new Thickness(0, 6, 0, 0), IsVisible = false }; _description = new Label { FontSize = 12 }; _description.Token(Label.TextColorProperty, ShellToken.MutedForeground); - var card = new Border + _card = new Border { Content = new VerticalStackLayout { @@ -115,17 +161,19 @@ public StatCard() Children = { header, - new HorizontalStackLayout { Spacing = 8, Margin = new Thickness(0, 8, 0, 0), Children = { _value, _pill } }, - _description + _valueRow, + _valueSkeleton, + _description, + _descriptionSkeleton } }, Padding = new Thickness(20), StrokeThickness = 1, StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusLg } }; - card.Token(VisualElement.BackgroundColorProperty, ShellToken.Card); - card.Token(Border.StrokeProperty, ShellToken.Border); - Content = card; + _card.Token(VisualElement.BackgroundColorProperty, ShellToken.Card); + _card.Token(Border.StrokeProperty, ShellToken.Border); + Content = _card; Update(); } @@ -135,6 +183,20 @@ private void Update() _icon.Name = Icon; _icon.IsVisible = Icon != IconName.None; _value.Text = Value ?? string.Empty; + _value.Token(Label.TextColorProperty, ValueTone switch + { + StatTone.Success => ShellToken.Success, + StatTone.Warning => ShellToken.Warning, + StatTone.Destructive => ShellToken.Destructive, + _ => ShellToken.CardForeground + }); + + // Sm — p-4, text-xl value, text-xs title. + var small = Size == StatCardSize.Sm; + _card.Padding = small ? new Thickness(16) : new Thickness(20); + _title.FontSize = small ? 12 : 14; + _value.FontSize = small ? 20 : 24; + _valueSkeleton.HeightRequest = small ? 24 : 28; _change.Text = Change ?? string.Empty; _pill.IsVisible = !string.IsNullOrEmpty(Change); @@ -149,9 +211,17 @@ private void Update() _change.Token(Label.TextColorProperty, text); _description.Text = Description ?? string.Empty; - _description.IsVisible = !string.IsNullOrEmpty(Description); + _description.IsVisible = !string.IsNullOrEmpty(Description) && !IsLoading; _description.Margin = new Thickness(0, 4, 0, 0); + + _valueRow.IsVisible = !IsLoading; + _valueSkeleton.IsVisible = IsLoading; + _descriptionSkeleton.IsVisible = IsLoading && !string.IsNullOrEmpty(Description); } } public enum StatTrend { Neutral, Up, Down } + +public enum StatTone { Default, Success, Warning, Destructive } + +public enum StatCardSize { Default, Sm } diff --git a/examples/MAUI.Demo/Components/UI/Variants/ButtonVariants.cs b/examples/MAUI.Demo/Components/UI/Variants/ButtonVariants.cs index 9c8409b..8b81510 100644 --- a/examples/MAUI.Demo/Components/UI/Variants/ButtonVariants.cs +++ b/examples/MAUI.Demo/Components/UI/Variants/ButtonVariants.cs @@ -15,7 +15,9 @@ public enum ButtonSize Default, Sm, Lg, - Icon + Icon, + // No height or padding: a Link button that sits in a line of text or at a card's edge (h-auto p-0). + Inline } // Token-based style for one variant + size. Null background/border = transparent. @@ -92,6 +94,10 @@ public static ButtonStyle GetStyle(ButtonVariant variant = ButtonVariant.Default style.Width = 40; style.Padding = new Thickness(0); break; + case ButtonSize.Inline: // h-auto p-0 + style.Height = -1; + style.Padding = new Thickness(0); + break; } return style; diff --git a/examples/MAUI.Demo/MainPage.xaml b/examples/MAUI.Demo/MainPage.xaml index a5db875..0f734b5 100644 --- a/examples/MAUI.Demo/MainPage.xaml +++ b/examples/MAUI.Demo/MainPage.xaml @@ -92,6 +92,10 @@ + + @@ -185,7 +189,7 @@ + + + + + @@ -424,8 +452,10 @@ - diff --git a/examples/MAUI.Demo/MainPage.xaml.cs b/examples/MAUI.Demo/MainPage.xaml.cs index 8502905..210ae0b 100644 --- a/examples/MAUI.Demo/MainPage.xaml.cs +++ b/examples/MAUI.Demo/MainPage.xaml.cs @@ -9,6 +9,14 @@ public partial class MainPage : ContentPage public MainPage() { InitializeComponent(); + // The status column draws a badge; sorting and the filter still use Property. + DataTableDemo.Columns[2].Cell = item => new Badge + { + Text = ((DemoInvoice)item).Status, + Variant = ((DemoInvoice)item).Status switch { "Paid" => BadgeVariant.Success, "Pending" => BadgeVariant.Warning, _ => BadgeVariant.Destructive }, + HorizontalOptions = LayoutOptions.Start, + VerticalOptions = LayoutOptions.Center + }; DataTableDemo.ItemsSource = new List { new DemoInvoice("INV001", "Shewart Shepherd", "Paid", 250.00m, new DateTime(2026, 9, 2)), @@ -24,6 +32,15 @@ public MainPage() new DemoInvoice("INV011", "Shewart Shepherd", "Unpaid", 640.00m, new DateTime(2026, 10, 4)), new DemoInvoice("INV012", "ShellUI", "Paid", 310.25m, new DateTime(2026, 10, 8)), }; + // Spans take theme tokens like any element, so they follow the theme when it changes. + RichTextLabel.FormattedText = new FormattedString + { + Spans = + { + new Span { Text = "Order #1042 is on hold: card declined" }.Token(Span.TextColorProperty, ShellToken.Foreground), + new Span { Text = " · 2 min ago" }.Token(Span.TextColorProperty, ShellToken.MutedForeground) + } + }; CountrySelect.ItemsSource = new List { "United States", "Canada", "United Kingdom", "Germany", "Japan" }; TagsInput.Tags = new List { "maui", "design-system" }; FrameworkMultiSelect.ItemsSource = new List diff --git a/scripts/generate-icons.py b/scripts/generate-icons.py index 23534a1..5db93f8 100644 --- a/scripts/generate-icons.py +++ b/scripts/generate-icons.py @@ -50,10 +50,10 @@ "info", "circle-alert", "circle-check", "circle-x", "circle-help", "triangle-alert", "ban", "loader", "loader-circle", "bell", "circle", "circle-plus", "dot", # people / account - "user", "users", "user-plus", "lock", "lock-open", "key", "shield", "eye", "eye-off", + "user", "users", "user-plus", "lock", "lock-open", "key", "key-round", "shield", "eye", "eye-off", # content / objects - "mail", "inbox", "archive", "message-circle", "phone", "calendar", "clock", "globe", - "map-pin", "image", "file", "file-text", "folder", "tag", "bookmark", "heart", "star", + "mail", "inbox", "archive", "message-circle", "phone", "calendar", "calendar-clock", "clock", "globe", + "map-pin", "image", "file", "file-text", "receipt", "list-checks", "folder", "tag", "bookmark", "heart", "star", "thumbs-up", "shopping-cart", "credit-card", "dollar-sign", "activity", "trending-up", "chart-column", "zap", "sparkles", "command", "terminal", "code", "moon", "sun", "monitor", "smartphone", "laptop", "wifi", "camera", "mic", "play", "pause", "volume-2", "lightbulb", diff --git a/scripts/sync-templates.py b/scripts/sync-templates.py index ba70cfe..67d2e79 100644 --- a/scripts/sync-templates.py +++ b/scripts/sync-templates.py @@ -108,6 +108,14 @@ ["block", "edge-menu", "dock", "tools", "mobile"]), "BottomNav": ("bottom-nav", "Bottom Nav", "Mobile tab bar: icon tabs with badges, a raised action and four styles", "Navigation", ["bottom-nav", "tab-bar", "navigation", "mobile", "tabs"]), + "SheetHeader": ("sheet-header", "Sheet Header", "Title and description at the top of a sheet", "Overlay", + ["overlay", "sheet", "header"]), + "SheetTitle": ("sheet-title", "Sheet Title", "Title text of a sheet", "Overlay", + ["overlay", "sheet", "title"]), + "SheetDescription": ("sheet-description", "Sheet Description", "Muted description under a sheet's title", "Overlay", + ["overlay", "sheet", "description"]), + "SheetFooter": ("sheet-footer", "Sheet Footer", "Row of actions at the end of a sheet", "Overlay", + ["overlay", "sheet", "footer"]), "Sidebar": ("sidebar", "Sidebar", "App sidebar with groups and items that collapses to an icon rail", "Navigation", ["sidebar", "navigation", "menu", "layout"]), "ButtonGroup": ("button-group", "Button Group", "Buttons joined into one control", "Form", diff --git a/src/ShellUI.Native.Templates/ComponentRegistry.cs b/src/ShellUI.Native.Templates/ComponentRegistry.cs index 9b8e44e..6a5066e 100644 --- a/src/ShellUI.Native.Templates/ComponentRegistry.cs +++ b/src/ShellUI.Native.Templates/ComponentRegistry.cs @@ -56,6 +56,10 @@ public static class ComponentRegistry { "sheet", (SheetTemplate.Metadata, SheetTemplate.Contents) }, { "sheet-trigger", (SheetTriggerTemplate.Metadata, SheetTriggerTemplate.Contents) }, { "sheet-content", (SheetContentTemplate.Metadata, SheetContentTemplate.Contents) }, + { "sheet-header", (SheetHeaderTemplate.Metadata, SheetHeaderTemplate.Contents) }, + { "sheet-footer", (SheetFooterTemplate.Metadata, SheetFooterTemplate.Contents) }, + { "sheet-title", (SheetTitleTemplate.Metadata, SheetTitleTemplate.Contents) }, + { "sheet-description", (SheetDescriptionTemplate.Metadata, SheetDescriptionTemplate.Contents) }, { "dropdown", (DropdownTemplate.Metadata, DropdownTemplate.Contents) }, { "dropdown-trigger", (DropdownTriggerTemplate.Metadata, DropdownTriggerTemplate.Contents) }, { "dropdown-content", (DropdownContentTemplate.Metadata, DropdownContentTemplate.Contents) }, diff --git a/src/ShellUI.Native.Templates/Templates/ButtonVariantsTemplate.cs b/src/ShellUI.Native.Templates/Templates/ButtonVariantsTemplate.cs index 586d95a..a52d9cf 100644 --- a/src/ShellUI.Native.Templates/Templates/ButtonVariantsTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/ButtonVariantsTemplate.cs @@ -35,7 +35,9 @@ public enum ButtonSize Default, Sm, Lg, - Icon + Icon, + // No height or padding: a Link button that sits in a line of text or at a card's edge (h-auto p-0). + Inline } // Token-based style for one variant + size. Null background/border = transparent. @@ -112,6 +114,10 @@ public static ButtonStyle GetStyle(ButtonVariant variant = ButtonVariant.Default style.Width = 40; style.Padding = new Thickness(0); break; + case ButtonSize.Inline: // h-auto p-0 + style.Height = -1; + style.Padding = new Thickness(0); + break; } return style; @@ -137,7 +143,9 @@ public enum ButtonSize Default, Sm, Lg, - Icon + Icon, + // No height or padding: a Link button that sits in a line of text or at a card's edge (h-auto p-0). + Inline } // Token-based style for one variant + size. Null background/border = transparent. @@ -214,6 +222,10 @@ public static ButtonStyle GetStyle(ButtonVariant variant = ButtonVariant.Default style.Width = 40; style.Padding = new Thickness(0); break; + case ButtonSize.Inline: // h-auto p-0 + style.Height = double.NaN; + style.Padding = new Thickness(0); + break; } return style; diff --git a/src/ShellUI.Native.Templates/Templates/CardHeaderTemplate.cs b/src/ShellUI.Native.Templates/Templates/CardHeaderTemplate.cs index ef38752..6cd5a96 100644 --- a/src/ShellUI.Native.Templates/Templates/CardHeaderTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/CardHeaderTemplate.cs @@ -22,6 +22,15 @@ public static class CardHeaderTemplate [NativePlatform.MAUI] = @"namespace YourProjectNamespace.Components.UI; // Card header — p-6 space-y-1.5; title font-semibold, description text-sm text-muted-foreground. +// Views inside sit on the title's line (badges, a count) and wrap under it when there is no room; +// Action sits at the top right (shadcn's CardAction). +// +// +// +// +// +// +[ContentProperty(nameof(Children))] public partial class CardHeader : ContentView { public static readonly BindableProperty TitleProperty = @@ -32,8 +41,14 @@ public partial class CardHeader : ContentView BindableProperty.Create(nameof(Description), typeof(string), typeof(CardHeader), string.Empty, propertyChanged: (b, o, n) => (b as CardHeader)?.UpdateText()); + public static readonly BindableProperty ActionProperty = + BindableProperty.Create(nameof(Action), typeof(View), typeof(CardHeader), null, + propertyChanged: (b, o, n) => ((CardHeader)b).UpdateAction()); + private readonly Label _title; private readonly Label _description; + private readonly FlexLayout _titleLine; + private readonly ContentView _action; public string Title { @@ -47,36 +62,100 @@ public string Description set => SetValue(DescriptionProperty, value); } + // Any view at the top right: a button, a menu, a select. + public View? Action + { + get => (View?)GetValue(ActionProperty); + set => SetValue(ActionProperty, value); + } + + // Views after the title, on its line. + public new IList Children { get; } + public CardHeader() { - _title = new Label { FontSize = 18, FontAttributes = FontAttributes.Bold }; + _title = new Label { FontSize = 18, FontAttributes = FontAttributes.Bold, IsVisible = false, Margin = new Thickness(0, 0, 8, 0) }; _title.Token(Label.TextColorProperty, ShellToken.CardForeground); _description = new Label { FontSize = 14, IsVisible = false }; _description.Token(Label.TextColorProperty, ShellToken.MutedForeground); - Content = new VerticalStackLayout + // FlexLayout has no gap: the title and each item get 8 on their right. + _titleLine = new FlexLayout { Wrap = Microsoft.Maui.Layouts.FlexWrap.Wrap, AlignItems = Microsoft.Maui.Layouts.FlexAlignItems.Center, Children = { _title } }; + Children = new TitleLine(_titleLine); + _action = new ContentView { VerticalOptions = LayoutOptions.Start, IsVisible = false }; + + var grid = new Grid { - Spacing = 6, Padding = new Thickness(24, 24, 24, 16), - Children = { _title, _description } + ColumnSpacing = 16, + ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto) } }; + grid.Add(new VerticalStackLayout { Spacing = 6, Children = { _titleLine, _description } }, 0, 0); + grid.Add(_action, 1, 0); + Content = grid; } private void UpdateText() { _title.Text = Title ?? string.Empty; + _title.IsVisible = !string.IsNullOrEmpty(Title); _description.Text = Description ?? string.Empty; _description.IsVisible = !string.IsNullOrEmpty(Description); } + + private void UpdateAction() + { + _action.Content = Action; + _action.IsVisible = Action != null; + } + + // The title line's items after the title label, spaced like gap-2. + private sealed class TitleLine(FlexLayout line) : System.Collections.ObjectModel.Collection + { + protected override void InsertItem(int index, IView item) + { + base.InsertItem(index, item); + if (item is View view && view.Margin == default) view.Margin = new Thickness(0, 2, 8, 2); + line.Children.Insert(index + 1, item); + } + + protected override void RemoveItem(int index) + { + base.RemoveItem(index); + line.Children.RemoveAt(index + 1); + } + + protected override void SetItem(int index, IView item) + { + base.SetItem(index, item); + line.Children[index + 1] = item; + } + + protected override void ClearItems() + { + base.ClearItems(); + while (line.Children.Count > 1) line.Children.RemoveAt(1); + } + } } ", [NativePlatform.Avalonia] = @"using Avalonia; using Avalonia.Controls; +using Avalonia.Layout; using Avalonia.Media; +using Avalonia.Metadata; namespace YourProjectNamespace.Components.UI; // Card header — p-6 space-y-1.5; title font-semibold, description text-sm text-muted-foreground. +// Controls inside sit on the title's line (badges, a count) and wrap under it when there is no +// room; Action sits at the top right (shadcn's CardAction). +// +// +// +// +// +// public class CardHeader : Border { public static readonly StyledProperty TitleProperty = @@ -85,13 +164,19 @@ public class CardHeader : Border public static readonly StyledProperty DescriptionProperty = AvaloniaProperty.Register(nameof(Description)); + public static readonly StyledProperty ActionProperty = + AvaloniaProperty.Register(nameof(Action)); + private readonly TextBlock _title; private readonly TextBlock _description; + private readonly WrapPanel _titleLine; + private readonly ContentControl _action; static CardHeader() { TitleProperty.Changed.AddClassHandler((h, _) => h.UpdateText()); DescriptionProperty.Changed.AddClassHandler((h, _) => h.UpdateText()); + ActionProperty.Changed.AddClassHandler((h, _) => h.UpdateAction()); } public string? Title @@ -106,23 +191,58 @@ public string? Description set => SetValue(DescriptionProperty, value); } + // Any control at the top right: a button, a menu, a select. + public Control? Action + { + get => GetValue(ActionProperty); + set => SetValue(ActionProperty, value); + } + + // Controls after the title, on its line. + [Content] + public Controls Items { get; } = new(); + public CardHeader() { - _title = new TextBlock { FontSize = 18, FontWeight = FontWeight.SemiBold, TextWrapping = TextWrapping.Wrap }; + _title = new TextBlock { FontSize = 18, FontWeight = FontWeight.SemiBold, TextWrapping = TextWrapping.Wrap, IsVisible = false, VerticalAlignment = VerticalAlignment.Center }; _title.Token(TextBlock.ForegroundProperty, ShellToken.CardForeground); _description = new TextBlock { FontSize = 14, TextWrapping = TextWrapping.Wrap, IsVisible = false }; _description.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); - Child = new StackPanel { Spacing = 6, Children = { _title, _description } }; + _titleLine = new WrapPanel { ItemSpacing = 8, LineSpacing = 4, Children = { _title } }; + Items.CollectionChanged += (_, _) => + { + while (_titleLine.Children.Count > 1) _titleLine.Children.RemoveAt(1); + foreach (var item in Items) + { + item.VerticalAlignment = VerticalAlignment.Center; + _titleLine.Children.Add(item); + } + }; + _action = new ContentControl { VerticalAlignment = VerticalAlignment.Top, IsVisible = false }; + + var grid = new Grid { ColumnDefinitions = new ColumnDefinitions(""*,Auto""), ColumnSpacing = 16 }; + var text = new StackPanel { Spacing = 6, Children = { _titleLine, _description } }; + grid.Children.Add(text); + Grid.SetColumn(_action, 1); + grid.Children.Add(_action); + Child = grid; Padding = new Thickness(24, 24, 24, 16); } private void UpdateText() { _title.Text = Title ?? string.Empty; + _title.IsVisible = !string.IsNullOrEmpty(Title); _description.Text = Description ?? string.Empty; _description.IsVisible = !string.IsNullOrEmpty(Description); } + + private void UpdateAction() + { + _action.Content = Action; + _action.IsVisible = Action != null; + } } " }; diff --git a/src/ShellUI.Native.Templates/Templates/DataTableTemplate.cs b/src/ShellUI.Native.Templates/Templates/DataTableTemplate.cs index 79f021c..86ca364 100644 --- a/src/ShellUI.Native.Templates/Templates/DataTableTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/DataTableTemplate.cs @@ -35,6 +35,8 @@ namespace YourProjectNamespace.Components.UI; // // A column reads its Property from each item by name. With trimming or NativeAOT, set the // column's Value in code instead: column.Value = item => ((Invoice)item).Number; +// For a view in a cell (a badge, a link), set Cell in code: +// column.Cell = item => new Badge { Text = ((Invoice)item).Status }; (before ItemsSource) [ContentProperty(nameof(Columns))] public partial class DataTable : ContentView { @@ -309,7 +311,9 @@ private void Render(IReadOnlyList rows) } _rowViews.Add((item, row, check)); foreach (var column in _columns) - row.Children.Add(new TableCell { Text = column.Text(item), IsBold = column.IsBold, HorizontalTextAlignment = column.HorizontalTextAlignment }); + row.Children.Add(column.Cell is { } cell + ? new TableCell { Content = cell(item) } + : new TableCell { Text = column.Text(item), IsBold = column.IsBold, HorizontalTextAlignment = column.HorizontalTextAlignment }); _table.Rows.Add(row); } @@ -434,6 +438,11 @@ public partial class DataTableColumn // Reads the column's value from an item; replaces Property. public Func? Value { get; set; } + // Draws the cell as any view (a link, a badge, an amount); replaces the text. Filter and sort + // still use Value or Property, so set one of them for a column that sorts or is searched. Set it + // before ItemsSource, or call Refresh() after. + public Func? Cell { get; set; } + // A .NET format string for the value, e.g. ""C"" or ""MMM d, yyyy"". public string? Format { get; set; } @@ -493,6 +502,8 @@ namespace YourProjectNamespace.Components.UI; // // A column reads its Property from each item by name. With trimming or NativeAOT, set the // column's Value in code instead: column.Value = item => ((Invoice)item).Number; +// For a view in a cell (a badge, a link), set Cell in code: +// column.Cell = item => new Badge { Text = ((Invoice)item).Status }; (before ItemsSource) public class DataTable : Border { public static readonly StyledProperty ItemsSourceProperty = @@ -769,7 +780,9 @@ private void Render(IReadOnlyList rows) } _rowViews.Add((item, row, check)); foreach (var column in Columns) - row.Children.Add(new TableCell { Text = column.Text(item), IsBold = column.IsBold, HorizontalTextAlignment = column.HorizontalTextAlignment }); + row.Children.Add(column.Cell is { } cell + ? new TableCell { Child = cell(item) } + : new TableCell { Text = column.Text(item), IsBold = column.IsBold, HorizontalTextAlignment = column.HorizontalTextAlignment }); _table.Rows.Add(row); } @@ -914,6 +927,11 @@ public class DataTableColumn // Reads the column's value from an item; replaces Property. public Func? Value { get; set; } + // Draws the cell as any control (a link, a badge, an amount); replaces the text. Filter and sort + // still use Value or Property, so set one of them for a column that sorts or is searched. Set it + // before ItemsSource, or call Refresh() after. + public Func? Cell { get; set; } + // A .NET format string for the value, e.g. ""C"" or ""MMM d, yyyy"". public string? Format { get; set; } diff --git a/src/ShellUI.Native.Templates/Templates/IconTemplate.cs b/src/ShellUI.Native.Templates/Templates/IconTemplate.cs index 5132915..58d3593 100644 --- a/src/ShellUI.Native.Templates/Templates/IconTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/IconTemplate.cs @@ -24,7 +24,7 @@ namespace YourProjectNamespace.Components.UI; // Stroke icons from ShellIcons (https://github.com/shellui-dev/shell-icons), derived from // Lucide 0.475.0 (ISC license) — the icon set ShellUI uses. Drawn with MAUI shapes: no font or // package, crisp at any size, theme-aware. Generated by scripts/generate-icons.py; a curated set -// of 110 icons — add names there and re-run to include more. +// of 114 icons — add names there and re-run to include more. // Usage: Tint with Token=""MutedForeground"" or Color=""Red"". public enum IconName { @@ -95,6 +95,7 @@ public enum IconName Lock, LockOpen, Key, + KeyRound, Shield, Eye, EyeOff, @@ -104,12 +105,15 @@ public enum IconName MessageCircle, Phone, Calendar, + CalendarClock, Clock, Globe, MapPin, Image, File, FileText, + Receipt, + ListChecks, Folder, Tag, Bookmark, @@ -212,6 +216,7 @@ public static class IconPaths IconName.Lock => ""M 5 11 L 19 11 C 20.105 11 21 11.895 21 13 L 21 20 C 21 21.105 20.105 22 19 22 L 5 22 C 3.895 22 3 21.105 3 20 L 3 13 C 3 11.895 3.895 11 5 11 Z M 7 11 L 7 7 C 7 4.239 9.239 2 12 2 C 14.761 2 17 4.239 17 7 L 17 11"", IconName.LockOpen => ""M 5 11 L 19 11 C 20.105 11 21 11.895 21 13 L 21 20 C 21 21.105 20.105 22 19 22 L 5 22 C 3.895 22 3 21.105 3 20 L 3 13 C 3 11.895 3.895 11 5 11 Z M 7 11 L 7 7 C 6.997 4.431 8.942 2.278 11.498 2.02 C 14.053 1.762 16.389 3.483 16.9 6"", IconName.Key => ""M 15.5 7.5 L 17.8 9.8 C 18.189 10.181 18.811 10.181 19.2 9.8 L 21.3 7.7 C 21.681 7.311 21.681 6.689 21.3 6.3 L 19 4 M 21 2 L 11.4 11.6 M 13 15.5 C 13 18.538 10.538 21 7.5 21 C 4.462 21 2 18.538 2 15.5 C 2 12.462 4.462 10 7.5 10 C 10.538 10 13 12.462 13 15.5 Z"", + IconName.KeyRound => ""M 2.586 17.414 C 2.211 17.789 2 18.298 2 18.828 L 2 21 C 2 21.552 2.448 22 3 22 L 6 22 C 6.552 22 7 21.552 7 21 L 7 20 C 7 19.448 7.448 19 8 19 L 9 19 C 9.552 19 10 18.552 10 18 L 10 17 C 10 16.448 10.448 16 11 16 L 11.172 16 C 11.702 16 12.211 15.789 12.586 15.414 L 13.4 14.6 C 16.262 15.597 19.434 14.484 21.044 11.916 C 22.655 9.349 22.278 6.009 20.134 3.866 C 17.991 1.722 14.651 1.345 12.084 2.956 C 9.516 4.566 8.403 7.738 9.4 10.6 Z M 17 7.5 C 17 7.776 16.776 8 16.5 8 C 16.224 8 16 7.776 16 7.5 C 16 7.224 16.224 7 16.5 7 C 16.776 7 17 7.224 17 7.5 Z"", IconName.Shield => ""M 20 13 C 20 18 16.5 20.5 12.34 21.95 C 12.122 22.024 11.886 22.02 11.67 21.94 C 7.5 20.5 4 18 4 13 L 4 6 C 4 5.448 4.448 5 5 5 C 7 5 9.5 3.8 11.24 2.28 C 11.678 1.906 12.322 1.906 12.76 2.28 C 14.51 3.81 17 5 19 5 C 19.552 5 20 5.448 20 6 Z"", IconName.Eye => ""M 2.062 12.348 C 1.979 12.123 1.979 11.877 2.062 11.652 C 3.722 7.627 7.646 5.001 12 5.001 C 16.354 5.001 20.278 7.627 21.938 11.652 C 22.021 11.877 22.021 12.123 21.938 12.348 C 20.278 16.373 16.354 18.999 12 18.999 C 7.646 18.999 3.722 16.373 2.062 12.348 M 15 12 C 15 13.657 13.657 15 12 15 C 10.343 15 9 13.657 9 12 C 9 10.343 10.343 9 12 9 C 13.657 9 15 10.343 15 12 Z"", IconName.EyeOff => ""M 10.733 5.076 C 15.52 4.506 20.101 7.194 21.938 11.651 C 22.021 11.876 22.021 12.122 21.938 12.347 C 21.57 13.238 21.085 14.076 20.494 14.837 M 14.084 14.158 C 12.907 15.295 11.036 15.279 9.879 14.121 C 8.721 12.964 8.705 11.093 9.842 9.916 M 17.479 17.499 C 14.795 19.089 11.553 19.434 8.594 18.446 C 5.635 17.457 3.251 15.232 2.062 12.348 C 1.979 12.123 1.979 11.877 2.062 11.652 C 2.949 9.502 4.509 7.697 6.508 6.509 M 2 2 L 22 22"", @@ -221,12 +226,15 @@ public static class IconPaths IconName.MessageCircle => ""M 7.9 20 C 11.843 22.023 16.672 20.874 19.282 17.292 C 21.892 13.71 21.505 8.762 18.372 5.628 C 15.238 2.495 10.29 2.108 6.708 4.718 C 3.126 7.328 1.977 12.157 4 16.1 L 2 22 Z"", IconName.Phone => ""M 22 16.92 L 22 19.92 C 22.002 20.483 21.767 21.021 21.352 21.402 C 20.937 21.783 20.381 21.971 19.82 21.92 C 16.743 21.586 13.787 20.534 11.19 18.85 C 8.774 17.315 6.725 15.266 5.19 12.85 C 3.5 10.241 2.448 7.271 2.12 4.18 C 2.069 3.621 2.256 3.066 2.635 2.652 C 3.013 2.237 3.549 2.001 4.11 2 L 7.11 2 C 8.114 1.99 8.97 2.726 9.11 3.72 C 9.237 4.68 9.471 5.623 9.81 6.53 C 10.085 7.261 9.909 8.085 9.36 8.64 L 8.09 9.91 C 9.514 12.414 11.586 14.486 14.09 15.91 L 15.36 14.64 C 15.915 14.091 16.739 13.915 17.47 14.19 C 18.377 14.529 19.32 14.763 20.28 14.89 C 21.286 15.032 22.025 15.905 22 16.92 Z"", IconName.Calendar => ""M 8 2 L 8 6 M 16 2 L 16 6 M 5 4 L 19 4 C 20.105 4 21 4.895 21 6 L 21 20 C 21 21.105 20.105 22 19 22 L 5 22 C 3.895 22 3 21.105 3 20 L 3 6 C 3 4.895 3.895 4 5 4 Z M 3 10 L 21 10"", + IconName.CalendarClock => ""M 21 7.5 L 21 6 C 21 4.895 20.105 4 19 4 L 5 4 C 3.895 4 3 4.895 3 6 L 3 20 C 3 21.105 3.895 22 5 22 L 8.5 22 M 16 2 L 16 6 M 8 2 L 8 6 M 3 10 L 8 10 M 17.5 17.5 L 16 16.3 L 16 14 M 22 16 C 22 19.314 19.314 22 16 22 C 12.686 22 10 19.314 10 16 C 10 12.686 12.686 10 16 10 C 19.314 10 22 12.686 22 16 Z"", IconName.Clock => ""M 22 12 C 22 17.523 17.523 22 12 22 C 6.477 22 2 17.523 2 12 C 2 6.477 6.477 2 12 2 C 17.523 2 22 6.477 22 12 Z M 12 6 L 12 12 L 16 14"", IconName.Globe => ""M 22 12 C 22 17.523 17.523 22 12 22 C 6.477 22 2 17.523 2 12 C 2 6.477 6.477 2 12 2 C 17.523 2 22 6.477 22 12 Z M 12 2 C 6.667 7.6 6.667 16.4 12 22 C 17.333 16.4 17.333 7.6 12 2 M 2 12 L 22 12"", IconName.MapPin => ""M 20 10 C 20 14.993 14.461 20.193 12.601 21.799 C 12.245 22.067 11.755 22.067 11.399 21.799 C 9.539 20.193 4 14.993 4 10 C 4 5.582 7.582 2 12 2 C 16.418 2 20 5.582 20 10 M 15 10 C 15 11.657 13.657 13 12 13 C 10.343 13 9 11.657 9 10 C 9 8.343 10.343 7 12 7 C 13.657 7 15 8.343 15 10 Z"", IconName.Image => ""M 5 3 L 19 3 C 20.105 3 21 3.895 21 5 L 21 19 C 21 20.105 20.105 21 19 21 L 5 21 C 3.895 21 3 20.105 3 19 L 3 5 C 3 3.895 3.895 3 5 3 Z M 11 9 C 11 10.105 10.105 11 9 11 C 7.895 11 7 10.105 7 9 C 7 7.895 7.895 7 9 7 C 10.105 7 11 7.895 11 9 Z M 21 15 L 17.914 11.914 C 17.133 11.133 15.867 11.133 15.086 11.914 L 6 21"", IconName.File => ""M 15 2 L 6 2 C 4.895 2 4 2.895 4 4 L 4 20 C 4 21.105 4.895 22 6 22 L 18 22 C 19.105 22 20 21.105 20 20 L 20 7 Z M 14 2 L 14 6 C 14 7.105 14.895 8 16 8 L 20 8"", IconName.FileText => ""M 15 2 L 6 2 C 4.895 2 4 2.895 4 4 L 4 20 C 4 21.105 4.895 22 6 22 L 18 22 C 19.105 22 20 21.105 20 20 L 20 7 Z M 14 2 L 14 6 C 14 7.105 14.895 8 16 8 L 20 8 M 10 9 L 8 9 M 16 13 L 8 13 M 16 17 L 8 17"", + IconName.Receipt => ""M 4 2 L 4 22 L 6 21 L 8 22 L 10 21 L 12 22 L 14 21 L 16 22 L 18 21 L 20 22 L 20 2 L 18 3 L 16 2 L 14 3 L 12 2 L 10 3 L 8 2 L 6 3 Z M 16 8 L 10 8 C 8.895 8 8 8.895 8 10 C 8 11.105 8.895 12 10 12 L 14 12 C 15.105 12 16 12.895 16 14 C 16 15.105 15.105 16 14 16 L 8 16 M 12 17.5 L 12 6.5"", + IconName.ListChecks => ""M 3 17 L 5 19 L 9 15 M 3 7 L 5 9 L 9 5 M 13 6 L 21 6 M 13 12 L 21 12 M 13 18 L 21 18"", IconName.Folder => ""M 20 20 C 21.105 20 22 19.105 22 18 L 22 8 C 22 6.895 21.105 6 20 6 L 12.1 6 C 11.42 6.007 10.784 5.668 10.41 5.1 L 9.6 3.9 C 9.23 3.338 8.603 3 7.93 3 L 4 3 C 2.895 3 2 3.895 2 5 L 2 18 C 2 19.105 2.895 20 4 20 Z"", IconName.Tag => ""M 12.586 2.586 C 12.211 2.211 11.702 2 11.172 2 L 4 2 C 2.895 2 2 2.895 2 4 L 2 11.172 C 2 11.702 2.211 12.211 2.586 12.586 L 11.29 21.29 C 12.236 22.23 13.764 22.23 14.71 21.29 L 21.29 14.71 C 22.23 13.764 22.23 12.236 21.29 11.29 Z M 8 7.5 C 8 7.776 7.776 8 7.5 8 C 7.224 8 7 7.776 7 7.5 C 7 7.224 7.224 7 7.5 7 C 7.776 7 8 7.224 8 7.5 Z"", IconName.Bookmark => ""M 19 21 L 12 17 L 5 21 L 5 5 C 5 3.895 5.895 3 7 3 L 17 3 C 18.105 3 19 3.895 19 5 L 19 21 Z"", @@ -375,7 +383,7 @@ namespace YourProjectNamespace.Components.UI; // Stroke icons from ShellIcons (https://github.com/shellui-dev/shell-icons), derived from // Lucide 0.475.0 (ISC license) — the icon set ShellUI uses. Drawn as Avalonia geometry: no font or // package, crisp at any size, theme-aware. Generated by scripts/generate-icons.py; a curated set -// of 110 icons — add names there and re-run to include more. +// of 114 icons — add names there and re-run to include more. // Usage: Tint with Token=""MutedForeground"" or Color=""Red"". // The icon is picked with Kind: every Avalonia control already has a Name. public enum IconName @@ -447,6 +455,7 @@ public enum IconName Lock, LockOpen, Key, + KeyRound, Shield, Eye, EyeOff, @@ -456,12 +465,15 @@ public enum IconName MessageCircle, Phone, Calendar, + CalendarClock, Clock, Globe, MapPin, Image, File, FileText, + Receipt, + ListChecks, Folder, Tag, Bookmark, @@ -564,6 +576,7 @@ public static class IconPaths IconName.Lock => ""M 5 11 L 19 11 C 20.105 11 21 11.895 21 13 L 21 20 C 21 21.105 20.105 22 19 22 L 5 22 C 3.895 22 3 21.105 3 20 L 3 13 C 3 11.895 3.895 11 5 11 Z M 7 11 L 7 7 C 7 4.239 9.239 2 12 2 C 14.761 2 17 4.239 17 7 L 17 11"", IconName.LockOpen => ""M 5 11 L 19 11 C 20.105 11 21 11.895 21 13 L 21 20 C 21 21.105 20.105 22 19 22 L 5 22 C 3.895 22 3 21.105 3 20 L 3 13 C 3 11.895 3.895 11 5 11 Z M 7 11 L 7 7 C 6.997 4.431 8.942 2.278 11.498 2.02 C 14.053 1.762 16.389 3.483 16.9 6"", IconName.Key => ""M 15.5 7.5 L 17.8 9.8 C 18.189 10.181 18.811 10.181 19.2 9.8 L 21.3 7.7 C 21.681 7.311 21.681 6.689 21.3 6.3 L 19 4 M 21 2 L 11.4 11.6 M 13 15.5 C 13 18.538 10.538 21 7.5 21 C 4.462 21 2 18.538 2 15.5 C 2 12.462 4.462 10 7.5 10 C 10.538 10 13 12.462 13 15.5 Z"", + IconName.KeyRound => ""M 2.586 17.414 C 2.211 17.789 2 18.298 2 18.828 L 2 21 C 2 21.552 2.448 22 3 22 L 6 22 C 6.552 22 7 21.552 7 21 L 7 20 C 7 19.448 7.448 19 8 19 L 9 19 C 9.552 19 10 18.552 10 18 L 10 17 C 10 16.448 10.448 16 11 16 L 11.172 16 C 11.702 16 12.211 15.789 12.586 15.414 L 13.4 14.6 C 16.262 15.597 19.434 14.484 21.044 11.916 C 22.655 9.349 22.278 6.009 20.134 3.866 C 17.991 1.722 14.651 1.345 12.084 2.956 C 9.516 4.566 8.403 7.738 9.4 10.6 Z M 17 7.5 C 17 7.776 16.776 8 16.5 8 C 16.224 8 16 7.776 16 7.5 C 16 7.224 16.224 7 16.5 7 C 16.776 7 17 7.224 17 7.5 Z"", IconName.Shield => ""M 20 13 C 20 18 16.5 20.5 12.34 21.95 C 12.122 22.024 11.886 22.02 11.67 21.94 C 7.5 20.5 4 18 4 13 L 4 6 C 4 5.448 4.448 5 5 5 C 7 5 9.5 3.8 11.24 2.28 C 11.678 1.906 12.322 1.906 12.76 2.28 C 14.51 3.81 17 5 19 5 C 19.552 5 20 5.448 20 6 Z"", IconName.Eye => ""M 2.062 12.348 C 1.979 12.123 1.979 11.877 2.062 11.652 C 3.722 7.627 7.646 5.001 12 5.001 C 16.354 5.001 20.278 7.627 21.938 11.652 C 22.021 11.877 22.021 12.123 21.938 12.348 C 20.278 16.373 16.354 18.999 12 18.999 C 7.646 18.999 3.722 16.373 2.062 12.348 M 15 12 C 15 13.657 13.657 15 12 15 C 10.343 15 9 13.657 9 12 C 9 10.343 10.343 9 12 9 C 13.657 9 15 10.343 15 12 Z"", IconName.EyeOff => ""M 10.733 5.076 C 15.52 4.506 20.101 7.194 21.938 11.651 C 22.021 11.876 22.021 12.122 21.938 12.347 C 21.57 13.238 21.085 14.076 20.494 14.837 M 14.084 14.158 C 12.907 15.295 11.036 15.279 9.879 14.121 C 8.721 12.964 8.705 11.093 9.842 9.916 M 17.479 17.499 C 14.795 19.089 11.553 19.434 8.594 18.446 C 5.635 17.457 3.251 15.232 2.062 12.348 C 1.979 12.123 1.979 11.877 2.062 11.652 C 2.949 9.502 4.509 7.697 6.508 6.509 M 2 2 L 22 22"", @@ -573,12 +586,15 @@ public static class IconPaths IconName.MessageCircle => ""M 7.9 20 C 11.843 22.023 16.672 20.874 19.282 17.292 C 21.892 13.71 21.505 8.762 18.372 5.628 C 15.238 2.495 10.29 2.108 6.708 4.718 C 3.126 7.328 1.977 12.157 4 16.1 L 2 22 Z"", IconName.Phone => ""M 22 16.92 L 22 19.92 C 22.002 20.483 21.767 21.021 21.352 21.402 C 20.937 21.783 20.381 21.971 19.82 21.92 C 16.743 21.586 13.787 20.534 11.19 18.85 C 8.774 17.315 6.725 15.266 5.19 12.85 C 3.5 10.241 2.448 7.271 2.12 4.18 C 2.069 3.621 2.256 3.066 2.635 2.652 C 3.013 2.237 3.549 2.001 4.11 2 L 7.11 2 C 8.114 1.99 8.97 2.726 9.11 3.72 C 9.237 4.68 9.471 5.623 9.81 6.53 C 10.085 7.261 9.909 8.085 9.36 8.64 L 8.09 9.91 C 9.514 12.414 11.586 14.486 14.09 15.91 L 15.36 14.64 C 15.915 14.091 16.739 13.915 17.47 14.19 C 18.377 14.529 19.32 14.763 20.28 14.89 C 21.286 15.032 22.025 15.905 22 16.92 Z"", IconName.Calendar => ""M 8 2 L 8 6 M 16 2 L 16 6 M 5 4 L 19 4 C 20.105 4 21 4.895 21 6 L 21 20 C 21 21.105 20.105 22 19 22 L 5 22 C 3.895 22 3 21.105 3 20 L 3 6 C 3 4.895 3.895 4 5 4 Z M 3 10 L 21 10"", + IconName.CalendarClock => ""M 21 7.5 L 21 6 C 21 4.895 20.105 4 19 4 L 5 4 C 3.895 4 3 4.895 3 6 L 3 20 C 3 21.105 3.895 22 5 22 L 8.5 22 M 16 2 L 16 6 M 8 2 L 8 6 M 3 10 L 8 10 M 17.5 17.5 L 16 16.3 L 16 14 M 22 16 C 22 19.314 19.314 22 16 22 C 12.686 22 10 19.314 10 16 C 10 12.686 12.686 10 16 10 C 19.314 10 22 12.686 22 16 Z"", IconName.Clock => ""M 22 12 C 22 17.523 17.523 22 12 22 C 6.477 22 2 17.523 2 12 C 2 6.477 6.477 2 12 2 C 17.523 2 22 6.477 22 12 Z M 12 6 L 12 12 L 16 14"", IconName.Globe => ""M 22 12 C 22 17.523 17.523 22 12 22 C 6.477 22 2 17.523 2 12 C 2 6.477 6.477 2 12 2 C 17.523 2 22 6.477 22 12 Z M 12 2 C 6.667 7.6 6.667 16.4 12 22 C 17.333 16.4 17.333 7.6 12 2 M 2 12 L 22 12"", IconName.MapPin => ""M 20 10 C 20 14.993 14.461 20.193 12.601 21.799 C 12.245 22.067 11.755 22.067 11.399 21.799 C 9.539 20.193 4 14.993 4 10 C 4 5.582 7.582 2 12 2 C 16.418 2 20 5.582 20 10 M 15 10 C 15 11.657 13.657 13 12 13 C 10.343 13 9 11.657 9 10 C 9 8.343 10.343 7 12 7 C 13.657 7 15 8.343 15 10 Z"", IconName.Image => ""M 5 3 L 19 3 C 20.105 3 21 3.895 21 5 L 21 19 C 21 20.105 20.105 21 19 21 L 5 21 C 3.895 21 3 20.105 3 19 L 3 5 C 3 3.895 3.895 3 5 3 Z M 11 9 C 11 10.105 10.105 11 9 11 C 7.895 11 7 10.105 7 9 C 7 7.895 7.895 7 9 7 C 10.105 7 11 7.895 11 9 Z M 21 15 L 17.914 11.914 C 17.133 11.133 15.867 11.133 15.086 11.914 L 6 21"", IconName.File => ""M 15 2 L 6 2 C 4.895 2 4 2.895 4 4 L 4 20 C 4 21.105 4.895 22 6 22 L 18 22 C 19.105 22 20 21.105 20 20 L 20 7 Z M 14 2 L 14 6 C 14 7.105 14.895 8 16 8 L 20 8"", IconName.FileText => ""M 15 2 L 6 2 C 4.895 2 4 2.895 4 4 L 4 20 C 4 21.105 4.895 22 6 22 L 18 22 C 19.105 22 20 21.105 20 20 L 20 7 Z M 14 2 L 14 6 C 14 7.105 14.895 8 16 8 L 20 8 M 10 9 L 8 9 M 16 13 L 8 13 M 16 17 L 8 17"", + IconName.Receipt => ""M 4 2 L 4 22 L 6 21 L 8 22 L 10 21 L 12 22 L 14 21 L 16 22 L 18 21 L 20 22 L 20 2 L 18 3 L 16 2 L 14 3 L 12 2 L 10 3 L 8 2 L 6 3 Z M 16 8 L 10 8 C 8.895 8 8 8.895 8 10 C 8 11.105 8.895 12 10 12 L 14 12 C 15.105 12 16 12.895 16 14 C 16 15.105 15.105 16 14 16 L 8 16 M 12 17.5 L 12 6.5"", + IconName.ListChecks => ""M 3 17 L 5 19 L 9 15 M 3 7 L 5 9 L 9 5 M 13 6 L 21 6 M 13 12 L 21 12 M 13 18 L 21 18"", IconName.Folder => ""M 20 20 C 21.105 20 22 19.105 22 18 L 22 8 C 22 6.895 21.105 6 20 6 L 12.1 6 C 11.42 6.007 10.784 5.668 10.41 5.1 L 9.6 3.9 C 9.23 3.338 8.603 3 7.93 3 L 4 3 C 2.895 3 2 3.895 2 5 L 2 18 C 2 19.105 2.895 20 4 20 Z"", IconName.Tag => ""M 12.586 2.586 C 12.211 2.211 11.702 2 11.172 2 L 4 2 C 2.895 2 2 2.895 2 4 L 2 11.172 C 2 11.702 2.211 12.211 2.586 12.586 L 11.29 21.29 C 12.236 22.23 13.764 22.23 14.71 21.29 L 21.29 14.71 C 22.23 13.764 22.23 12.236 21.29 11.29 Z M 8 7.5 C 8 7.776 7.776 8 7.5 8 C 7.224 8 7 7.776 7 7.5 C 7 7.224 7.224 7 7.5 7 C 7.776 7 8 7.224 8 7.5 Z"", IconName.Bookmark => ""M 19 21 L 12 17 L 5 21 L 5 5 C 5 3.895 5.895 3 7 3 L 17 3 C 18.105 3 19 3.895 19 5 L 19 21 Z"", diff --git a/src/ShellUI.Native.Templates/Templates/InputGroupTemplate.cs b/src/ShellUI.Native.Templates/Templates/InputGroupTemplate.cs index c3485fe..103351d 100644 --- a/src/ShellUI.Native.Templates/Templates/InputGroupTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/InputGroupTemplate.cs @@ -11,7 +11,7 @@ public static class InputGroupTemplate Description = "Input with an icon, text or view before or after it", Category = ComponentCategory.Form, FilePath = "InputGroup.cs", - Dependencies = new List { "shell", "icon" }, + Dependencies = new List { "shell", "icon", "input" }, Tags = new List { "input", "group", "prefix", "suffix", "addon" } }; @@ -28,6 +28,7 @@ namespace YourProjectNamespace.Components.UI; // // ...any view... // +// Variant=""Password"" masks the text and adds an eye button after the suffix that shows it; IsPassword only masks. public partial class InputGroup : ContentView { public static readonly BindableProperty TextProperty = @@ -44,7 +45,11 @@ public partial class InputGroup : ContentView public static readonly BindableProperty IsPasswordProperty = BindableProperty.Create(nameof(IsPassword), typeof(bool), typeof(InputGroup), false, - propertyChanged: (b, o, n) => ((InputGroup)b)._entry.IsPassword = (bool)n); + propertyChanged: (b, o, n) => ((InputGroup)b).UpdatePassword()); + + public static readonly BindableProperty VariantProperty = + BindableProperty.Create(nameof(Variant), typeof(InputVariant), typeof(InputGroup), InputVariant.Default, + propertyChanged: (b, o, n) => ((InputGroup)b).UpdatePassword()); public static readonly BindableProperty HasErrorProperty = BindableProperty.Create(nameof(HasError), typeof(bool), typeof(InputGroup), false, @@ -98,6 +103,12 @@ public bool IsPassword set => SetValue(IsPasswordProperty, value); } + public InputVariant Variant + { + get => (InputVariant)GetValue(VariantProperty); + set => SetValue(VariantProperty, value); + } + public bool HasError { get => (bool)GetValue(HasErrorProperty); @@ -153,7 +164,10 @@ public View? Suffix private readonly Border _border; private readonly ContentView _prefix; private readonly ContentView _suffix; + private readonly Border _reveal; + private readonly Icon _revealIcon; private bool _isFocused; + private bool _revealed; public InputGroup() { @@ -186,12 +200,15 @@ public InputGroup() ColumnSpacing = 8, ColumnDefinitions = { - new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto) + new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto), + new ColumnDefinition(GridLength.Auto) } }; + (_reveal, _revealIcon) = CreateRevealButton(); row.Add(_prefix, 0, 0); row.Add(_entry, 1, 0); row.Add(_suffix, 2, 0); + row.Add(_reveal, 3, 0); _border = new Border { @@ -221,6 +238,50 @@ protected override void OnPropertyChanged(string? propertyName = null) Opacity = IsEnabled ? 1.0 : 0.5; } + // A ghost icon button, h-7 w-7, that pulls into the padding a little. + private (Border, Icon) CreateRevealButton() + { + var icon = new Icon { Name = IconName.Eye, Size = 16, Token = ShellToken.MutedForeground }; + var button = new Border + { + Content = icon, + WidthRequest = 28, + HeightRequest = 28, + StrokeThickness = 0, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusSm }, + BackgroundColor = Colors.Transparent, + VerticalOptions = LayoutOptions.Center, + Margin = new Thickness(-4, 0, -6, 0), + IsVisible = false + }; + SemanticProperties.SetDescription(button, ""Show password""); + var pointer = new PointerGestureRecognizer(); + pointer.PointerEntered += (_, _) => button.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent); + pointer.PointerExited += (_, _) => { button.ClearValue(VisualElement.BackgroundColorProperty); button.BackgroundColor = Colors.Transparent; }; + button.GestureRecognizers.Add(pointer); + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => ToggleReveal(); + button.GestureRecognizers.Add(tap); + ShellFocus.MakeFocusable(button, ToggleReveal); + return (button, icon); + } + + private void ToggleReveal() + { + _revealed = !_revealed; + UpdatePassword(); + } + + private void UpdatePassword() + { + var password = Variant == InputVariant.Password; + if (!password) _revealed = false; + _entry.IsPassword = (IsPassword || password) && !_revealed; + _reveal.IsVisible = password; + _revealIcon.Name = _revealed ? IconName.EyeOff : IconName.Eye; + SemanticProperties.SetDescription(_reveal, _revealed ? ""Hide password"" : ""Show password""); + } + private void UpdateSlots() { _prefix.Content = Prefix ?? Slot(PrefixIcon, PrefixText); @@ -263,6 +324,7 @@ private void UpdateVisualState() ", [NativePlatform.Avalonia] = @"using System; using Avalonia; +using Avalonia.Automation; using Avalonia.Controls; using Avalonia.Data; using Avalonia.Input; @@ -278,6 +340,7 @@ namespace YourProjectNamespace.Components.UI; // // ...any control... // +// Variant=""Password"" masks the text and adds an eye button after the suffix that shows it; IsPassword only masks. public class InputGroup : Border { public static readonly StyledProperty TextProperty = @@ -289,6 +352,9 @@ public class InputGroup : Border public static readonly StyledProperty IsPasswordProperty = AvaloniaProperty.Register(nameof(IsPassword)); + public static readonly StyledProperty VariantProperty = + AvaloniaProperty.Register(nameof(Variant)); + public static readonly StyledProperty HasErrorProperty = AvaloniaProperty.Register(nameof(HasError)); @@ -313,6 +379,9 @@ public class InputGroup : Border AvaloniaProperty.Register(nameof(Suffix)); private readonly TextBox _textBox; + private readonly Border _reveal; + private readonly Icon _revealIcon; + private bool _revealed; private readonly Border _prefix = new() { VerticalAlignment = VerticalAlignment.Center, IsVisible = false }; private readonly Border _suffix = new() { VerticalAlignment = VerticalAlignment.Center, IsVisible = false }; private bool _isFocused; @@ -324,7 +393,8 @@ static InputGroup() if (g._textBox.Text != (string?)e.NewValue) g._textBox.Text = (string?)e.NewValue; }); PlaceholderProperty.Changed.AddClassHandler((g, e) => g._textBox.PlaceholderText = (string?)e.NewValue); - IsPasswordProperty.Changed.AddClassHandler((g, e) => g._textBox.PasswordChar = (bool)e.NewValue! ? '•' : default); + IsPasswordProperty.Changed.AddClassHandler((g, _) => g.UpdatePassword()); + VariantProperty.Changed.AddClassHandler((g, _) => g.UpdatePassword()); HasErrorProperty.Changed.AddClassHandler((g, _) => g.UpdateVisualState()); IsEnabledProperty.Changed.AddClassHandler((g, _) => g.Opacity = g.IsEnabled ? 1.0 : 0.5); PrefixIconProperty.Changed.AddClassHandler((g, _) => g.UpdateSlots()); @@ -353,6 +423,12 @@ public bool IsPassword set => SetValue(IsPasswordProperty, value); } + public InputVariant Variant + { + get => GetValue(VariantProperty); + set => SetValue(VariantProperty, value); + } + public bool HasError { get => GetValue(HasErrorProperty); @@ -423,11 +499,13 @@ public InputGroup() Grid.SetColumn(_textBox, 1); Grid.SetColumn(_suffix, 2); + (_reveal, _revealIcon) = CreateRevealButton(); + Grid.SetColumn(_reveal, 3); Child = new Grid { - ColumnDefinitions = new ColumnDefinitions(""Auto,*,Auto""), + ColumnDefinitions = new ColumnDefinitions(""Auto,*,Auto,Auto""), ColumnSpacing = 8, - Children = { _prefix, _textBox, _suffix } + Children = { _prefix, _textBox, _suffix, _reveal } }; Height = 40; Padding = new Thickness(12, 0); @@ -488,6 +566,58 @@ private void UpdateSlots() return row; } + // A ghost icon button, h-7 w-7, that pulls into the padding a little. + private (Border, Icon) CreateRevealButton() + { + var icon = new Icon { Kind = IconName.Eye, Size = 16, Token = ShellToken.MutedForeground }; + var button = new Border + { + Child = icon, + Width = 28, + Height = 28, + CornerRadius = new CornerRadius(ShellTheme.RadiusSm), + Background = Brushes.Transparent, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(-4, 0, -6, 0), + Cursor = new Cursor(StandardCursorType.Hand), + IsVisible = false + }; + AutomationProperties.SetName(button, ""Show password""); + button.PointerEntered += (_, _) => button.Token(BackgroundProperty, ShellToken.Accent); + button.PointerExited += (_, _) => { button.ClearToken(BackgroundProperty); button.Background = Brushes.Transparent; }; + // Handled, so the press neither focuses the button nor leaves the text box. + button.PointerPressed += (_, e) => + { + ToggleReveal(); + _textBox.Focus(); + e.Handled = true; + }; + ShellFocus.Ring(button, button); + button.KeyDown += (_, e) => + { + if (e.Key is not (Key.Enter or Key.Space)) return; + ToggleReveal(); + e.Handled = true; + }; + return (button, icon); + } + + private void ToggleReveal() + { + _revealed = !_revealed; + UpdatePassword(); + } + + private void UpdatePassword() + { + var password = Variant == InputVariant.Password; + if (!password) _revealed = false; + _textBox.PasswordChar = (IsPassword || password) && !_revealed ? '•' : default; + _reveal.IsVisible = password; + _revealIcon.Kind = _revealed ? IconName.EyeOff : IconName.Eye; + AutomationProperties.SetName(_reveal, _revealed ? ""Hide password"" : ""Show password""); + } + private void UpdateVisualState() { var stroke = HasError ? ShellToken.Destructive : _isFocused ? ShellToken.Ring : ShellToken.Input; diff --git a/src/ShellUI.Native.Templates/Templates/InputTemplate.cs b/src/ShellUI.Native.Templates/Templates/InputTemplate.cs index e0ee924..7148066 100644 --- a/src/ShellUI.Native.Templates/Templates/InputTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/InputTemplate.cs @@ -12,7 +12,7 @@ public static class InputTemplate Description = "Text input field with placeholder, validation states, and icon support", Category = ComponentCategory.Form, FilePath = "Input.cs", - Dependencies = new List { "shell" }, + Dependencies = new List { "shell", "icon" }, Variants = new List { "default", "error", "success" }, Tags = new List { "form", "input", "text", "field", "entry" } }; @@ -26,6 +26,7 @@ namespace YourProjectNamespace.Components.UI; // Text input — h-10 rounded-md border border-input px-3 text-sm. // Focus: border-ring + soft ring glow. Error: border-destructive. // The native Entry's own frame is stripped so this Border is the only chrome. +// Variant=""Password"" masks the text and adds an eye button that shows it; IsPassword only masks. public partial class Input : ContentView { public static readonly BindableProperty TextProperty = @@ -38,7 +39,11 @@ public partial class Input : ContentView public static readonly BindableProperty IsPasswordProperty = BindableProperty.Create(nameof(IsPassword), typeof(bool), typeof(Input), - false, propertyChanged: (b, o, n) => ((Input)b)._entry.IsPassword = (bool)n); + false, propertyChanged: (b, o, n) => ((Input)b).UpdatePassword()); + + public static readonly BindableProperty VariantProperty = + BindableProperty.Create(nameof(Variant), typeof(InputVariant), typeof(Input), InputVariant.Default, + propertyChanged: (b, o, n) => ((Input)b).UpdatePassword()); public static readonly BindableProperty HasErrorProperty = BindableProperty.Create(nameof(HasError), typeof(bool), typeof(Input), @@ -58,7 +63,10 @@ public partial class Input : ContentView private readonly Entry _entry; private readonly Border _border; + private readonly Border _reveal; + private readonly Icon _revealIcon; private bool _isFocused; + private bool _revealed; public string Text { @@ -78,6 +86,12 @@ public bool IsPassword set => SetValue(IsPasswordProperty, value); } + public InputVariant Variant + { + get => (InputVariant)GetValue(VariantProperty); + set => SetValue(VariantProperty, value); + } + public bool HasError { get => (bool)GetValue(HasErrorProperty); @@ -129,9 +143,17 @@ public Input() _entry.Focused += (s, e) => { _isFocused = true; UpdateVisualState(); }; _entry.Unfocused += (s, e) => { _isFocused = false; UpdateVisualState(); }; + (_reveal, _revealIcon) = CreateRevealButton(); + var row = new Grid + { + ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto) } + }; + row.Add(_entry, 0, 0); + row.Add(_reveal, 1, 0); + _border = new Border { - Content = _entry, + Content = row, Padding = new Thickness(12, 0), HeightRequest = 40, StrokeThickness = 1, @@ -148,6 +170,50 @@ public Input() UpdateVisualState(); } + // A ghost icon button, h-7 w-7, that pulls into the padding a little. + private (Border, Icon) CreateRevealButton() + { + var icon = new Icon { Name = IconName.Eye, Size = 16, Token = ShellToken.MutedForeground }; + var button = new Border + { + Content = icon, + WidthRequest = 28, + HeightRequest = 28, + StrokeThickness = 0, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusSm }, + BackgroundColor = Colors.Transparent, + VerticalOptions = LayoutOptions.Center, + Margin = new Thickness(4, 0, -6, 0), + IsVisible = false + }; + SemanticProperties.SetDescription(button, ""Show password""); + var pointer = new PointerGestureRecognizer(); + pointer.PointerEntered += (_, _) => button.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent); + pointer.PointerExited += (_, _) => { button.ClearValue(VisualElement.BackgroundColorProperty); button.BackgroundColor = Colors.Transparent; }; + button.GestureRecognizers.Add(pointer); + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => ToggleReveal(); + button.GestureRecognizers.Add(tap); + ShellFocus.MakeFocusable(button, ToggleReveal); + return (button, icon); + } + + private void ToggleReveal() + { + _revealed = !_revealed; + UpdatePassword(); + } + + private void UpdatePassword() + { + var password = Variant == InputVariant.Password; + if (!password) _revealed = false; + _entry.IsPassword = (IsPassword || password) && !_revealed; + _reveal.IsVisible = password; + _revealIcon.Name = _revealed ? IconName.EyeOff : IconName.Eye; + SemanticProperties.SetDescription(_reveal, _revealed ? ""Hide password"" : ""Show password""); + } + private static void OnTextChanged(BindableObject bindable, object oldValue, object newValue) { if (bindable is Input input && input._entry.Text != newValue as string) @@ -179,9 +245,13 @@ private void UpdateVisualState() public new void Focus() => _entry.Focus(); public new void Unfocus() => _entry.Unfocus(); } + +// Password masks the text and shows an eye button that reveals it. +public enum InputVariant { Default, Password } ", [NativePlatform.Avalonia] = @"using System; using Avalonia; +using Avalonia.Automation; using Avalonia.Controls; using Avalonia.Data; using Avalonia.Input; @@ -193,6 +263,7 @@ namespace YourProjectNamespace.Components.UI; // Text input — h-10 rounded-md border border-input px-3 text-sm. // Focus: border-ring + soft ring glow. Error: border-destructive. // The inner TextBox's Fluent chrome is stripped so this Border is the only frame. +// Variant=""Password"" masks the text and adds an eye button that shows it; IsPassword only masks. public class Input : Border { public static readonly StyledProperty TextProperty = @@ -204,6 +275,9 @@ public class Input : Border public static readonly StyledProperty IsPasswordProperty = AvaloniaProperty.Register(nameof(IsPassword)); + public static readonly StyledProperty VariantProperty = + AvaloniaProperty.Register(nameof(Variant)); + public static readonly StyledProperty HasErrorProperty = AvaloniaProperty.Register(nameof(HasError)); @@ -214,6 +288,9 @@ public class Input : Border AvaloniaProperty.Register(nameof(MaxLength)); private readonly TextBox _textBox; + private readonly Border _reveal; + private readonly Icon _revealIcon; + private bool _revealed; private bool _isFocused; static Input() @@ -223,7 +300,8 @@ static Input() if (i._textBox.Text != (string?)e.NewValue) i._textBox.Text = (string?)e.NewValue; }); PlaceholderProperty.Changed.AddClassHandler((i, e) => i._textBox.PlaceholderText = (string?)e.NewValue); - IsPasswordProperty.Changed.AddClassHandler((i, e) => i._textBox.PasswordChar = (bool)e.NewValue! ? '•' : default); + IsPasswordProperty.Changed.AddClassHandler((i, _) => i.UpdatePassword()); + VariantProperty.Changed.AddClassHandler((i, _) => i.UpdatePassword()); IsReadOnlyProperty.Changed.AddClassHandler((i, e) => i._textBox.IsReadOnly = (bool)e.NewValue!); MaxLengthProperty.Changed.AddClassHandler((i, e) => i._textBox.MaxLength = (int)e.NewValue!); HasErrorProperty.Changed.AddClassHandler((i, _) => i.UpdateVisualState()); @@ -248,6 +326,12 @@ public bool IsPassword set => SetValue(IsPasswordProperty, value); } + public InputVariant Variant + { + get => GetValue(VariantProperty); + set => SetValue(VariantProperty, value); + } + public bool HasError { get => GetValue(HasErrorProperty); @@ -294,7 +378,9 @@ public Input() _textBox.GotFocus += (_, _) => { _isFocused = true; UpdateVisualState(); }; _textBox.LostFocus += (_, _) => { _isFocused = false; UpdateVisualState(); }; - Child = _textBox; + (_reveal, _revealIcon) = CreateRevealButton(); + Grid.SetColumn(_reveal, 1); + Child = new Grid { ColumnDefinitions = new ColumnDefinitions(""*,Auto""), Children = { _textBox, _reveal } }; Height = 40; Padding = new Thickness(12, 0); BorderThickness = new Thickness(1); @@ -329,6 +415,58 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) public new bool Focus(NavigationMethod method = NavigationMethod.Unspecified, KeyModifiers keyModifiers = KeyModifiers.None) => _textBox.Focus(method, keyModifiers); + // A ghost icon button, h-7 w-7, that pulls into the padding a little. + private (Border, Icon) CreateRevealButton() + { + var icon = new Icon { Kind = IconName.Eye, Size = 16, Token = ShellToken.MutedForeground }; + var button = new Border + { + Child = icon, + Width = 28, + Height = 28, + CornerRadius = new CornerRadius(ShellTheme.RadiusSm), + Background = Brushes.Transparent, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(4, 0, -6, 0), + Cursor = new Cursor(StandardCursorType.Hand), + IsVisible = false + }; + AutomationProperties.SetName(button, ""Show password""); + button.PointerEntered += (_, _) => button.Token(BackgroundProperty, ShellToken.Accent); + button.PointerExited += (_, _) => { button.ClearToken(BackgroundProperty); button.Background = Brushes.Transparent; }; + // Handled, so the press neither focuses the button nor leaves the text box. + button.PointerPressed += (_, e) => + { + ToggleReveal(); + _textBox.Focus(); + e.Handled = true; + }; + ShellFocus.Ring(button, button); + button.KeyDown += (_, e) => + { + if (e.Key is not (Key.Enter or Key.Space)) return; + ToggleReveal(); + e.Handled = true; + }; + return (button, icon); + } + + private void ToggleReveal() + { + _revealed = !_revealed; + UpdatePassword(); + } + + private void UpdatePassword() + { + var password = Variant == InputVariant.Password; + if (!password) _revealed = false; + _textBox.PasswordChar = (IsPassword || password) && !_revealed ? '•' : default; + _reveal.IsVisible = password; + _revealIcon.Kind = _revealed ? IconName.EyeOff : IconName.Eye; + AutomationProperties.SetName(_reveal, _revealed ? ""Hide password"" : ""Show password""); + } + private void UpdateVisualState() { var stroke = HasError ? ShellToken.Destructive : _isFocused ? ShellToken.Ring : ShellToken.Input; @@ -338,6 +476,9 @@ private void UpdateVisualState() : default; } } + +// Password masks the text and shows an eye button that reveals it. +public enum InputVariant { Default, Password } " }; } diff --git a/src/ShellUI.Native.Templates/Templates/SheetDescriptionTemplate.cs b/src/ShellUI.Native.Templates/Templates/SheetDescriptionTemplate.cs new file mode 100644 index 0000000..f6f5f66 --- /dev/null +++ b/src/ShellUI.Native.Templates/Templates/SheetDescriptionTemplate.cs @@ -0,0 +1,49 @@ +using ShellUI.Native.Core.Models; + +namespace ShellUI.Native.Templates.Templates; + +public static class SheetDescriptionTemplate +{ + public static ComponentMetadata Metadata => new() + { + Name = "sheet-description", + DisplayName = "Sheet Description", + Description = "Muted description under a sheet's title", + Category = ComponentCategory.Overlay, + FilePath = "SheetDescription.cs", + Dependencies = new List { "shell" }, + Tags = new List { "overlay", "sheet", "description" } + }; + + public static IReadOnlyDictionary Contents { get; } = new Dictionary + { + [NativePlatform.MAUI] = @"namespace YourProjectNamespace.Components.UI; + +// text-sm text-muted-foreground +public partial class SheetDescription : Label +{ + public SheetDescription() + { + FontSize = 14; + this.Token(TextColorProperty, ShellToken.MutedForeground); + } +} +", + [NativePlatform.Avalonia] = @"using Avalonia.Controls; +using Avalonia.Media; + +namespace YourProjectNamespace.Components.UI; + +// text-sm text-muted-foreground +public class SheetDescription : TextBlock +{ + public SheetDescription() + { + FontSize = 14; + TextWrapping = TextWrapping.Wrap; + this.Token(ForegroundProperty, ShellToken.MutedForeground); + } +} +" + }; +} diff --git a/src/ShellUI.Native.Templates/Templates/SheetFooterTemplate.cs b/src/ShellUI.Native.Templates/Templates/SheetFooterTemplate.cs new file mode 100644 index 0000000..3a1dfd6 --- /dev/null +++ b/src/ShellUI.Native.Templates/Templates/SheetFooterTemplate.cs @@ -0,0 +1,61 @@ +using ShellUI.Native.Core.Models; + +namespace ShellUI.Native.Templates.Templates; + +public static class SheetFooterTemplate +{ + public static ComponentMetadata Metadata => new() + { + Name = "sheet-footer", + DisplayName = "Sheet Footer", + Description = "Row of actions at the end of a sheet", + Category = ComponentCategory.Overlay, + FilePath = "SheetFooter.cs", + Dependencies = new List(), + Tags = new List { "overlay", "sheet", "footer" } + }; + + public static IReadOnlyDictionary Contents { get; } = new Dictionary + { + [NativePlatform.MAUI] = @"namespace YourProjectNamespace.Components.UI; + +// Actions row at the end of a sheet — flex justify-end gap-2. +[ContentProperty(nameof(Children))] +public partial class SheetFooter : ContentView +{ + private readonly HorizontalStackLayout _stack; + + public new IList Children => _stack.Children; + + public SheetFooter() + { + _stack = new HorizontalStackLayout + { + Spacing = 8, + HorizontalOptions = LayoutOptions.End, + Margin = new Thickness(0, 8, 0, 0) + }; + Content = _stack; + } +} +", + [NativePlatform.Avalonia] = @"using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; + +namespace YourProjectNamespace.Components.UI; + +// Actions row at the end of a sheet — flex justify-end gap-2. +public class SheetFooter : StackPanel +{ + public SheetFooter() + { + Orientation = Orientation.Horizontal; + Spacing = 8; + HorizontalAlignment = HorizontalAlignment.Right; + Margin = new Thickness(0, 8, 0, 0); + } +} +" + }; +} diff --git a/src/ShellUI.Native.Templates/Templates/SheetHeaderTemplate.cs b/src/ShellUI.Native.Templates/Templates/SheetHeaderTemplate.cs new file mode 100644 index 0000000..34f63a6 --- /dev/null +++ b/src/ShellUI.Native.Templates/Templates/SheetHeaderTemplate.cs @@ -0,0 +1,67 @@ +using ShellUI.Native.Core.Models; + +namespace ShellUI.Native.Templates.Templates; + +public static class SheetHeaderTemplate +{ + public static ComponentMetadata Metadata => new() + { + Name = "sheet-header", + DisplayName = "Sheet Header", + Description = "Title and description at the top of a sheet", + Category = ComponentCategory.Overlay, + FilePath = "SheetHeader.cs", + Dependencies = new List(), + Tags = new List { "overlay", "sheet", "header" } + }; + + public static IReadOnlyDictionary Contents { get; } = new Dictionary + { + [NativePlatform.MAUI] = @"namespace YourProjectNamespace.Components.UI; + +// Title + description at the top of a sheet — flex flex-col space-y-2. +// +// +// +// +// +// ... +// +// +[ContentProperty(nameof(Children))] +public partial class SheetHeader : ContentView +{ + private readonly VerticalStackLayout _stack; + + public new IList Children => _stack.Children; + + public SheetHeader() + { + _stack = new VerticalStackLayout { Spacing = 8 }; + Content = _stack; + } +} +", + [NativePlatform.Avalonia] = @"using Avalonia.Controls; + +namespace YourProjectNamespace.Components.UI; + +// Title + description at the top of a sheet — flex flex-col space-y-2. +// +// +// +// +// +// ... +// +// +public class SheetHeader : StackPanel +{ + public SheetHeader() + { + Spacing = 8; + } +} +" + }; +} diff --git a/src/ShellUI.Native.Templates/Templates/SheetTemplate.cs b/src/ShellUI.Native.Templates/Templates/SheetTemplate.cs index 0373e76..fa68338 100644 --- a/src/ShellUI.Native.Templates/Templates/SheetTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/SheetTemplate.cs @@ -11,7 +11,7 @@ public static class SheetTemplate Description = "Bottom/top sheet overlay - use with SheetTrigger and SheetContent", Category = ComponentCategory.Overlay, FilePath = "Sheet.cs", - Dependencies = new List { "shell", "sheet-trigger", "sheet-content" }, + Dependencies = new List { "shell", "sheet-trigger", "sheet-content", "sheet-header", "sheet-footer", "sheet-title", "sheet-description" }, Variants = new List { "bottom", "top", "left", "right" }, Tags = new List { "overlay", "sheet", "panel" } }; diff --git a/src/ShellUI.Native.Templates/Templates/SheetTitleTemplate.cs b/src/ShellUI.Native.Templates/Templates/SheetTitleTemplate.cs new file mode 100644 index 0000000..4a247bf --- /dev/null +++ b/src/ShellUI.Native.Templates/Templates/SheetTitleTemplate.cs @@ -0,0 +1,51 @@ +using ShellUI.Native.Core.Models; + +namespace ShellUI.Native.Templates.Templates; + +public static class SheetTitleTemplate +{ + public static ComponentMetadata Metadata => new() + { + Name = "sheet-title", + DisplayName = "Sheet Title", + Description = "Title text of a sheet", + Category = ComponentCategory.Overlay, + FilePath = "SheetTitle.cs", + Dependencies = new List { "shell" }, + Tags = new List { "overlay", "sheet", "title" } + }; + + public static IReadOnlyDictionary Contents { get; } = new Dictionary + { + [NativePlatform.MAUI] = @"namespace YourProjectNamespace.Components.UI; + +// text-lg font-semibold text-foreground +public partial class SheetTitle : Label +{ + public SheetTitle() + { + FontSize = 18; + FontAttributes = FontAttributes.Bold; + this.Token(TextColorProperty, ShellToken.Foreground); + } +} +", + [NativePlatform.Avalonia] = @"using Avalonia.Controls; +using Avalonia.Media; + +namespace YourProjectNamespace.Components.UI; + +// text-lg font-semibold text-foreground +public class SheetTitle : TextBlock +{ + public SheetTitle() + { + FontSize = 18; + FontWeight = FontWeight.SemiBold; + TextWrapping = TextWrapping.Wrap; + this.Token(ForegroundProperty, ShellToken.Foreground); + } +} +" + }; +} diff --git a/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs index ca46d59..d4b6cd6 100644 --- a/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs @@ -162,8 +162,16 @@ public static void EnsureInitialized() if (app is null) return; _initialized = true; app.RequestedThemeChanged += (_, _) => Apply(); - // Set up each page's overlay layer as it appears. Doing it on first use instead would - // re-parent the page content under the user's finger (Android drops the scroll position). + // Set up each page's overlay layer as the page joins the app, before its views load, so + // wrapping the content doesn't unload and load them again (a view that cancels its work + // on Unloaded would lose its first load). + app.DescendantAdded += (_, e) => + { + if (e.Element is ContentPage { Content: not null } page) ShellPortal.GetLayer(page); + }; + // A page whose content is set later gets its layer as it appears. Doing it on first use + // instead would re-parent the page content under the user's finger (Android drops the + // scroll position). // Android starts a new activity, with unthemed bars, when the app is reopened after Back. app.PageAppearing += (_, page) => page.Dispatcher.Dispatch(() => { diff --git a/src/ShellUI.Native.Templates/Templates/StatCardTemplate.cs b/src/ShellUI.Native.Templates/Templates/StatCardTemplate.cs index f8f2215..c8e009d 100644 --- a/src/ShellUI.Native.Templates/Templates/StatCardTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/StatCardTemplate.cs @@ -11,7 +11,7 @@ public static class StatCardTemplate Description = "Dashboard metric with trend and description", Category = ComponentCategory.DataDisplay, FilePath = "StatCard.cs", - Dependencies = new List { "shell", "icon" }, + Dependencies = new List { "shell", "icon", "skeleton" }, Tags = new List { "stat", "metric", "kpi", "dashboard" } }; @@ -25,6 +25,8 @@ namespace YourProjectNamespace.Components.UI; // value in text-2xl bold, a change pill tinted by trend, and an optional description. // Usage: +// ValueTone tints the value (""Backup failed"" in red); a long value wraps and the pill drops under it. +// IsLoading shows skeletons in place of the value and description; Size=""Sm"" fits six to a row. public partial class StatCard : ContentView { public static readonly BindableProperty TitleProperty = @@ -51,6 +53,18 @@ public partial class StatCard : ContentView BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(StatCard), IconName.None, propertyChanged: (b, o, n) => ((StatCard)b).Update()); + public static readonly BindableProperty ValueToneProperty = + BindableProperty.Create(nameof(ValueTone), typeof(StatTone), typeof(StatCard), StatTone.Default, + propertyChanged: (b, o, n) => ((StatCard)b).Update()); + + public static readonly BindableProperty IsLoadingProperty = + BindableProperty.Create(nameof(IsLoading), typeof(bool), typeof(StatCard), false, + propertyChanged: (b, o, n) => ((StatCard)b).Update()); + + public static readonly BindableProperty SizeProperty = + BindableProperty.Create(nameof(Size), typeof(StatCardSize), typeof(StatCard), StatCardSize.Default, + propertyChanged: (b, o, n) => ((StatCard)b).Update()); + public string Title { get => (string)GetValue(TitleProperty); @@ -87,6 +101,28 @@ public IconName Icon set => SetValue(IconProperty, value); } + public StatTone ValueTone + { + get => (StatTone)GetValue(ValueToneProperty); + set => SetValue(ValueToneProperty, value); + } + + public bool IsLoading + { + get => (bool)GetValue(IsLoadingProperty); + set => SetValue(IsLoadingProperty, value); + } + + public StatCardSize Size + { + get => (StatCardSize)GetValue(SizeProperty); + set => SetValue(SizeProperty, value); + } + + private readonly Border _card; + private readonly FlexLayout _valueRow; + private readonly Skeleton _valueSkeleton; + private readonly Skeleton _descriptionSkeleton; private readonly Label _title; private readonly Icon _icon; private readonly Label _value; @@ -108,8 +144,8 @@ public StatCard() header.Add(_title, 0, 0); header.Add(_icon, 1, 0); - _value = new Label { FontSize = 24, FontAttributes = FontAttributes.Bold, VerticalTextAlignment = TextAlignment.Center }; - _value.Token(Label.TextColorProperty, ShellToken.CardForeground); + // FlexLayout has no gap: the value keeps 8 on its right, so the pill wraps under a long value. + _value = new Label { FontSize = 24, FontAttributes = FontAttributes.Bold, VerticalTextAlignment = TextAlignment.Center, LineBreakMode = LineBreakMode.WordWrap, Margin = new Thickness(0, 0, 8, 0) }; // The pill is the trend color at low strength behind text in the full color. _pillTint = new BoxView { BackgroundColor = Colors.Transparent }; @@ -120,13 +156,23 @@ public StatCard() StrokeThickness = 0, StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }, VerticalOptions = LayoutOptions.Center, + Margin = new Thickness(0, 2), BackgroundColor = Colors.Transparent }; + _valueRow = new FlexLayout + { + Wrap = Microsoft.Maui.Layouts.FlexWrap.Wrap, + AlignItems = Microsoft.Maui.Layouts.FlexAlignItems.Center, + Margin = new Thickness(0, 8, 0, 0), + Children = { _value, _pill } + }; + _valueSkeleton = new Skeleton { WidthRequest = 96, HeightRequest = 28, HorizontalOptions = LayoutOptions.Start, Margin = new Thickness(0, 10, 0, 2), IsVisible = false }; + _descriptionSkeleton = new Skeleton { WidthRequest = 128, HeightRequest = 12, HorizontalOptions = LayoutOptions.Start, Margin = new Thickness(0, 6, 0, 0), IsVisible = false }; _description = new Label { FontSize = 12 }; _description.Token(Label.TextColorProperty, ShellToken.MutedForeground); - var card = new Border + _card = new Border { Content = new VerticalStackLayout { @@ -134,17 +180,19 @@ public StatCard() Children = { header, - new HorizontalStackLayout { Spacing = 8, Margin = new Thickness(0, 8, 0, 0), Children = { _value, _pill } }, - _description + _valueRow, + _valueSkeleton, + _description, + _descriptionSkeleton } }, Padding = new Thickness(20), StrokeThickness = 1, StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusLg } }; - card.Token(VisualElement.BackgroundColorProperty, ShellToken.Card); - card.Token(Border.StrokeProperty, ShellToken.Border); - Content = card; + _card.Token(VisualElement.BackgroundColorProperty, ShellToken.Card); + _card.Token(Border.StrokeProperty, ShellToken.Border); + Content = _card; Update(); } @@ -154,6 +202,20 @@ private void Update() _icon.Name = Icon; _icon.IsVisible = Icon != IconName.None; _value.Text = Value ?? string.Empty; + _value.Token(Label.TextColorProperty, ValueTone switch + { + StatTone.Success => ShellToken.Success, + StatTone.Warning => ShellToken.Warning, + StatTone.Destructive => ShellToken.Destructive, + _ => ShellToken.CardForeground + }); + + // Sm — p-4, text-xl value, text-xs title. + var small = Size == StatCardSize.Sm; + _card.Padding = small ? new Thickness(16) : new Thickness(20); + _title.FontSize = small ? 12 : 14; + _value.FontSize = small ? 20 : 24; + _valueSkeleton.HeightRequest = small ? 24 : 28; _change.Text = Change ?? string.Empty; _pill.IsVisible = !string.IsNullOrEmpty(Change); @@ -168,12 +230,20 @@ private void Update() _change.Token(Label.TextColorProperty, text); _description.Text = Description ?? string.Empty; - _description.IsVisible = !string.IsNullOrEmpty(Description); + _description.IsVisible = !string.IsNullOrEmpty(Description) && !IsLoading; _description.Margin = new Thickness(0, 4, 0, 0); + + _valueRow.IsVisible = !IsLoading; + _valueSkeleton.IsVisible = IsLoading; + _descriptionSkeleton.IsVisible = IsLoading && !string.IsNullOrEmpty(Description); } } public enum StatTrend { Neutral, Up, Down } + +public enum StatTone { Default, Success, Warning, Destructive } + +public enum StatCardSize { Default, Sm } ", [NativePlatform.Avalonia] = @"using Avalonia; using Avalonia.Controls; @@ -186,6 +256,8 @@ namespace YourProjectNamespace.Components.UI; // value in text-2xl bold, a change pill tinted by trend, and an optional description. // Usage: +// ValueTone tints the value (""Backup failed"" in red); a long value wraps and the pill drops under it. +// IsLoading shows skeletons in place of the value and description; Size=""Sm"" fits six to a row. public class StatCard : Border { public static readonly StyledProperty TitleProperty = @@ -206,6 +278,15 @@ public class StatCard : Border public static readonly StyledProperty IconProperty = AvaloniaProperty.Register(nameof(Icon)); + public static readonly StyledProperty ValueToneProperty = + AvaloniaProperty.Register(nameof(ValueTone)); + + public static readonly StyledProperty IsLoadingProperty = + AvaloniaProperty.Register(nameof(IsLoading)); + + public static readonly StyledProperty SizeProperty = + AvaloniaProperty.Register(nameof(Size)); + private readonly TextBlock _title; private readonly Icon _icon; private readonly TextBlock _value; @@ -213,6 +294,9 @@ public class StatCard : Border private readonly Border _pillTint; private readonly TextBlock _change; private readonly TextBlock _description; + private readonly WrapPanel _valueRow; + private readonly Skeleton _valueSkeleton; + private readonly Skeleton _descriptionSkeleton; static StatCard() { @@ -222,6 +306,9 @@ static StatCard() TrendProperty.Changed.AddClassHandler((c, _) => c.Update()); DescriptionProperty.Changed.AddClassHandler((c, _) => c.Update()); IconProperty.Changed.AddClassHandler((c, _) => c.Update()); + ValueToneProperty.Changed.AddClassHandler((c, _) => c.Update()); + IsLoadingProperty.Changed.AddClassHandler((c, _) => c.Update()); + SizeProperty.Changed.AddClassHandler((c, _) => c.Update()); } public string? Title @@ -260,6 +347,24 @@ public IconName Icon set => SetValue(IconProperty, value); } + public StatTone ValueTone + { + get => GetValue(ValueToneProperty); + set => SetValue(ValueToneProperty, value); + } + + public bool IsLoading + { + get => GetValue(IsLoadingProperty); + set => SetValue(IsLoadingProperty, value); + } + + public StatCardSize Size + { + get => GetValue(SizeProperty); + set => SetValue(SizeProperty, value); + } + public StatCard() { _title = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; @@ -268,13 +373,16 @@ public StatCard() Grid.SetColumn(_icon, 1); var header = new Grid { ColumnDefinitions = new ColumnDefinitions(""*,Auto""), ColumnSpacing = 8, Children = { _title, _icon } }; - _value = new TextBlock { FontSize = 24, FontWeight = FontWeight.Bold, VerticalAlignment = VerticalAlignment.Center }; - _value.Token(TextBlock.ForegroundProperty, ShellToken.CardForeground); + _value = new TextBlock { FontSize = 24, FontWeight = FontWeight.Bold, VerticalAlignment = VerticalAlignment.Center, TextWrapping = TextWrapping.Wrap }; // The pill is the trend color at low strength behind text in the full color. _pillTint = new Border { CornerRadius = new CornerRadius(ShellTheme.RadiusMd) }; _change = new TextBlock { FontSize = 12, FontWeight = FontWeight.Medium, Margin = new Thickness(6, 2), VerticalAlignment = VerticalAlignment.Center }; - _pill = new Panel { VerticalAlignment = VerticalAlignment.Center, Children = { _pillTint, _change } }; + _pill = new Panel { VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 2), Children = { _pillTint, _change } }; + // The pill wraps under a long value. + _valueRow = new WrapPanel { ItemSpacing = 8, Margin = new Thickness(0, 8, 0, 0), Children = { _value, _pill } }; + _valueSkeleton = new Skeleton { Width = 96, Height = 28, HorizontalAlignment = HorizontalAlignment.Left, Margin = new Thickness(0, 10, 0, 2), IsVisible = false }; + _descriptionSkeleton = new Skeleton { Width = 128, Height = 12, HorizontalAlignment = HorizontalAlignment.Left, Margin = new Thickness(0, 6, 0, 0), IsVisible = false }; _description = new TextBlock { FontSize = 12, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 4, 0, 0) }; _description.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); @@ -284,11 +392,12 @@ public StatCard() Children = { header, - new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Margin = new Thickness(0, 8, 0, 0), Children = { _value, _pill } }, - _description + _valueRow, + _valueSkeleton, + _description, + _descriptionSkeleton } }; - Padding = new Thickness(20); BorderThickness = new Thickness(1); CornerRadius = new CornerRadius(ShellTheme.RadiusLg); this.Token(BackgroundProperty, ShellToken.Card).Token(BorderBrushProperty, ShellToken.Border); @@ -301,6 +410,20 @@ private void Update() _icon.Kind = Icon; _icon.IsVisible = Icon != IconName.None; _value.Text = Value ?? string.Empty; + _value.Token(TextBlock.ForegroundProperty, ValueTone switch + { + StatTone.Success => ShellToken.Success, + StatTone.Warning => ShellToken.Warning, + StatTone.Destructive => ShellToken.Destructive, + _ => ShellToken.CardForeground + }); + + // Sm — p-4, text-xl value, text-xs title. + var small = Size == StatCardSize.Sm; + Padding = small ? new Thickness(16) : new Thickness(20); + _title.FontSize = small ? 12 : 14; + _value.FontSize = small ? 20 : 24; + _valueSkeleton.Height = small ? 24 : 28; _change.Text = Change ?? string.Empty; _pill.IsVisible = !string.IsNullOrEmpty(Change); @@ -315,11 +438,19 @@ private void Update() _change.Token(TextBlock.ForegroundProperty, text); _description.Text = Description ?? string.Empty; - _description.IsVisible = !string.IsNullOrEmpty(Description); + _description.IsVisible = !string.IsNullOrEmpty(Description) && !IsLoading; + + _valueRow.IsVisible = !IsLoading; + _valueSkeleton.IsVisible = IsLoading; + _descriptionSkeleton.IsVisible = IsLoading && !string.IsNullOrEmpty(Description); } } public enum StatTrend { Neutral, Up, Down } + +public enum StatTone { Default, Success, Warning, Destructive } + +public enum StatCardSize { Default, Sm } " }; } diff --git a/tests/ShellUI.Native.Avalonia.Tests/SlotAndStateTests.cs b/tests/ShellUI.Native.Avalonia.Tests/SlotAndStateTests.cs new file mode 100644 index 0000000..bda2ee7 --- /dev/null +++ b/tests/ShellUI.Native.Avalonia.Tests/SlotAndStateTests.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Headless.XUnit; +using Avalonia.Media; +using Avalonia.Styling; +using Avalonia.VisualTree; +using AvaloniaDemo.Components.UI; +using AvaloniaDemo.Components.UI.Variants; +using Xunit; +using Button = AvaloniaDemo.Components.UI.Button; + +namespace ShellUI.Native.Avalonia.Tests; + +// Slots and states apps asked for: password reveal, cell views, inline links, card header +// actions, stat card tone / loading, and theme tokens on text runs. +public class SlotAndStateTests +{ + private static Border RevealButton(Control input) => + input.GetVisualDescendants().OfType().Single(b => AutomationProperties.GetName(b) is "Show password" or "Hide password"); + + private static TextBox TextBoxOf(Control input) => input.GetVisualDescendants().OfType().Single(); + + [AvaloniaFact] + public void Password_variant_reveal_button_shows_and_hides_the_text() + { + var input = new Input { Text = "secret", Variant = InputVariant.Password, Width = 300 }; + var window = Ui.Show(input); + var box = TextBoxOf(input); + var reveal = RevealButton(input); + Assert.True(reveal.IsVisible); + Assert.Equal('•', box.PasswordChar); + + Ui.Click(window, reveal); + Assert.Equal(default, box.PasswordChar); + Assert.Equal("Hide password", AutomationProperties.GetName(reveal)); + Assert.True(box.IsFocused); + + Ui.Click(window, reveal); + Assert.Equal('•', box.PasswordChar); + Assert.Equal("Show password", AutomationProperties.GetName(reveal)); + } + + [AvaloniaFact] + public void IsPassword_masks_without_a_button_and_the_variant_resets_when_changed() + { + var input = new Input { Text = "secret", IsPassword = true, Width = 300 }; + var window = Ui.Show(input); + Assert.False(RevealButton(input).IsVisible); + Assert.Equal('•', TextBoxOf(input).PasswordChar); + + input.Variant = InputVariant.Password; + Ui.Settle(); + Ui.Click(window, RevealButton(input)); + Assert.Equal(default, TextBoxOf(input).PasswordChar); + + input.Variant = InputVariant.Default; + Assert.False(RevealButton(input).IsVisible); + Assert.Equal('•', TextBoxOf(input).PasswordChar); + input.IsPassword = false; + Assert.Equal(default, TextBoxOf(input).PasswordChar); + } + + [AvaloniaFact] + public void InputGroup_reveal_button_sits_after_the_suffix() + { + var group = new InputGroup { Text = "secret", Variant = InputVariant.Password, SuffixText = "kg", Width = 300 }; + var window = Ui.Show(group); + var reveal = RevealButton(group); + var suffix = group.GetVisualDescendants().OfType().Single(t => t.Text == "kg"); + Assert.True(reveal.TranslatePoint(default, window)!.Value.X > suffix.TranslatePoint(default, window)!.Value.X); + + Ui.Click(window, reveal); + Assert.Equal(default, TextBoxOf(group).PasswordChar); + } + + private sealed record Row(string Name, string Status); + + [AvaloniaFact] + public void DataTable_column_cell_draws_a_control_and_still_sorts_by_its_property() + { + var table = new DataTable { Width = 600 }; + table.Columns.Add(new DataTableColumn { Header = "Name", Property = "Name" }); + table.Columns.Add(new DataTableColumn { Header = "Status", Property = "Status", Cell = item => new Badge { Text = "badge:" + ((Row)item).Status } }); + table.ItemsSource = new List { new("a", "Pending"), new("b", "Active") }; + Ui.Show(table); + + string[] Badges() => table.GetVisualDescendants().OfType().Select(b => b.Text!).ToArray(); + Assert.Equal(new[] { "badge:Pending", "badge:Active" }, Badges()); + Assert.DoesNotContain(table.GetVisualDescendants().OfType(), t => t.Text == "Pending"); + + table.SortBy(table.Columns[1]); + Ui.Settle(); + Assert.Equal(new[] { "badge:Active", "badge:Pending" }, Badges()); + } + + [AvaloniaFact] + public void Inline_button_has_no_padding_and_the_height_of_its_text() + { + var link = new Button { Text = "Sign in", Variant = ButtonVariant.Link, Size = ButtonSize.Inline }; + var normal = new Button { Text = "Sign in", Variant = ButtonVariant.Link }; + Ui.Show(new StackPanel { Children = { link, normal } }); + Assert.Equal(default, link.Padding); + Assert.True(link.Bounds.Height < 24, $"height {link.Bounds.Height}"); + // Default is px-4: 32 wider for the same text. + Assert.True(Math.Abs(normal.Bounds.Width - link.Bounds.Width - 32) < 1, $"{normal.Bounds.Width} vs {link.Bounds.Width}"); + } + + [AvaloniaFact] + public void CardHeader_puts_children_on_the_title_line_and_the_action_on_the_right() + { + var badge = new Badge { Text = "2" }; + var action = new Button { Text = "Export" }; + var header = new CardHeader { Title = "Needs review", Description = "Waiting for approval", Action = action, Width = 420 }; + header.Items.Add(badge); + var window = Ui.Show(header); + + var title = header.GetVisualDescendants().OfType().First(t => t.Text == "Needs review"); + var description = header.GetVisualDescendants().OfType().First(t => t.Text == "Waiting for approval"); + var titleAt = title.TranslatePoint(default, window)!.Value; + var badgeAt = badge.TranslatePoint(default, window)!.Value; + var actionAt = action.TranslatePoint(default, window)!.Value; + var headerAt = header.TranslatePoint(default, window)!.Value; + Assert.True(badgeAt.X > titleAt.X + title.Bounds.Width); + Assert.True(badgeAt.Y < description.TranslatePoint(default, window)!.Value.Y); + Assert.True(actionAt.X > badgeAt.X + badge.Bounds.Width); + // p-6: the action ends 24 in from the header's right edge. + Assert.True(Math.Abs(actionAt.X + action.Bounds.Width - headerAt.X - (420 - 24)) < 1, $"action ends at {actionAt.X + action.Bounds.Width - headerAt.X}"); + } + + [AvaloniaFact] + public void CardHeader_without_a_title_has_no_empty_title_line() + { + var header = new CardHeader { Description = "Only a description" }; + Ui.Show(header); + Assert.DoesNotContain(header.GetVisualDescendants().OfType(), t => t.IsEffectivelyVisible && string.IsNullOrEmpty(t.Text)); + } + + [AvaloniaFact] + public void StatCard_value_tone_follows_the_token() + { + var card = new StatCard { Title = "Last backup", Value = "Backup failed", ValueTone = StatTone.Destructive }; + Ui.Show(card); + var value = card.GetVisualDescendants().OfType().Single(t => t.Text == "Backup failed"); + Assert.Equal(ShellTheme.Light[ShellToken.Destructive], ((ISolidColorBrush)value.Foreground!).Color); + + Ui.Show(card = new StatCard { Value = "Backup failed", ValueTone = StatTone.Destructive }, ThemeVariant.Dark); + value = card.GetVisualDescendants().OfType().Single(t => t.Text == "Backup failed"); + Assert.Equal(ShellTheme.Dark[ShellToken.Destructive], ((ISolidColorBrush)value.Foreground!).Color); + } + + [AvaloniaFact] + public void StatCard_pill_wraps_under_a_long_value() + { + var card = new StatCard { Title = "Last backup", Value = "Backup failed", Change = "Action needed", Trend = StatTrend.Down, Width = 230 }; + var window = Ui.Show(card); + var value = card.GetVisualDescendants().OfType().Single(t => t.Text == "Backup failed"); + var change = card.GetVisualDescendants().OfType().Single(t => t.Text == "Action needed"); + var valueBottom = value.TranslatePoint(new Point(0, value.Bounds.Height), window)!.Value.Y; + Assert.True(change.TranslatePoint(default, window)!.Value.Y >= valueBottom - 1, $"value {value.TranslatePoint(default, window)} {value.Bounds.Size}, change {change.TranslatePoint(default, window)} {change.Bounds.Size}"); + Assert.True(change.TranslatePoint(new Point(change.Bounds.Width, 0), card)!.Value.X <= card.Bounds.Width); + } + + [AvaloniaFact] + public void StatCard_loading_shows_skeletons_in_place_of_the_value() + { + var card = new StatCard { Title = "Queue", Value = "12", Description = "waiting", IsLoading = true }; + Ui.Show(card); + var value = card.GetVisualDescendants().OfType().Single(t => t.Text == "12"); + Assert.False(value.IsEffectivelyVisible); + Assert.Equal(2, card.GetVisualDescendants().OfType().Count(s => s.IsEffectivelyVisible)); + + card.IsLoading = false; + Ui.Settle(); + Assert.True(value.IsEffectivelyVisible); + Assert.DoesNotContain(card.GetVisualDescendants().OfType(), s => s.IsEffectivelyVisible); + } + + [AvaloniaFact] + public void StatCard_small_size_is_more_compact() + { + var normal = new StatCard { Title = "Sent", Value = "1,204", Width = 160 }; + var small = new StatCard { Title = "Sent", Value = "1,204", Size = StatCardSize.Sm, Width = 160 }; + Ui.Show(new StackPanel { Children = { normal, small } }); + Assert.True(small.Bounds.Height < normal.Bounds.Height); + Assert.Equal(new Thickness(16), small.Padding); + } + + [AvaloniaFact] + public void Run_with_a_token_follows_the_theme() + { + var run = new Run(" · 2 min ago").Token(Run.ForegroundProperty, ShellToken.MutedForeground); + var text = new TextBlock(); + text.Inlines!.Add(run); + Ui.Show(text); + Assert.Equal(ShellTheme.Light[ShellToken.MutedForeground], ((ISolidColorBrush)run.Foreground!).Color); + + ShellTheme.SetTheme(ThemeVariant.Dark); + Ui.Settle(); + Assert.Equal(ShellTheme.Dark[ShellToken.MutedForeground], ((ISolidColorBrush)run.Foreground!).Color); + } +} diff --git a/tests/ShellUI.Native.Tests/ComponentRegistryTests.cs b/tests/ShellUI.Native.Tests/ComponentRegistryTests.cs index d20d5e5..0e57370 100644 --- a/tests/ShellUI.Native.Tests/ComponentRegistryTests.cs +++ b/tests/ShellUI.Native.Tests/ComponentRegistryTests.cs @@ -128,6 +128,10 @@ public void GetSupportedPlatforms_returns_empty_for_unknown_component() [InlineData("dialog-close", "dialog")] [InlineData("drawer-content", "drawer")] [InlineData("sheet-trigger", "sheet")] + [InlineData("sheet-header", "sheet")] + [InlineData("sheet-title", "sheet")] + [InlineData("sheet-description", "sheet")] + [InlineData("sheet-footer", "sheet")] [InlineData("dropdown-item", "dropdown")] [InlineData("popover-trigger", "popover")] [InlineData("hover-card-trigger", "hover-card")] @@ -141,6 +145,27 @@ public void GetFamily_maps_a_part_to_its_parent(string part, string family) Assert.Equal(family, ComponentRegistry.GetFamily(part)); } + // A loading stat card draws skeletons, so `add stat-card` installs skeleton too. + [Fact] + public void Stat_card_depends_on_skeleton() + { + Assert.Contains("skeleton", ComponentRegistry.Components["stat-card"].Dependencies); + } + + // InputVariant (Password) is declared with Input and shared by InputGroup. + [Fact] + public void Input_group_depends_on_input() + { + Assert.Contains("input", ComponentRegistry.Components["input-group"].Dependencies); + } + + [Fact] + public void Sheet_parts_have_both_platforms() + { + foreach (var part in new[] { "sheet-header", "sheet-title", "sheet-description", "sheet-footer" }) + Assert.Equal(new[] { NativePlatform.MAUI, NativePlatform.Avalonia }, ComponentRegistry.GetSupportedPlatforms(part).OrderBy(p => p)); + } + [Theory] [InlineData("dialog")] [InlineData("alert-dialog")]