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 @@
-
+
@@ -199,6 +203,7 @@
+
@@ -309,6 +314,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -319,6 +337,16 @@
Description="from last month" WidthRequest="230" Margin="0,0,12,12" />
+
+
+
+
+
+
+
+
@@ -424,8 +452,10 @@
-
-
+
+
+
+
@@ -434,7 +464,9 @@
-
+
+
+
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")]