Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 104 additions & 12 deletions docs/COMPONENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.

Expand Down Expand Up @@ -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 |
|----------|------|---------|-------------|
Expand All @@ -219,6 +241,16 @@ shellui-native add button
<ui:Button Icon="Settings" Size="Icon" Variant="Ghost" />
```

`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
<HorizontalStackLayout Spacing="4">
<Label Text="Already have an account?" />
<ui:Button Text="Sign in" Variant="Link" Size="Inline" />
</HorizontalStackLayout>
```

**Events:** `Clicked`. On Windows buttons are keyboard tab stops (Enter/Space activate).

---
Expand Down Expand Up @@ -301,18 +333,24 @@ 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 |
| Keyboard | Keyboard | Default | Soft keyboard type |

```xml
<ui:Input Placeholder="you@example.com" Keyboard="Email" Text="{Binding Email}" />
<ui:Input Placeholder="Password" IsPassword="True" />
<ui:Input Placeholder="Password" Variant="Password" />
<ui:Input Text="taken-name" HasError="True" />
```

`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`

---
Expand All @@ -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
<ui:InputGroup PrefixIcon="Search" Placeholder="Search..." />
<ui:InputGroup PrefixText="https://" SuffixText=".com" Placeholder="example" />
<ui:InputGroup PrefixIcon="Lock" Placeholder="Password" Variant="Password" />
<ui:InputGroup Placeholder="you@example.com">
<ui:InputGroup.Suffix>
<ui:Badge Text="Required" Variant="Secondary" />
Expand Down Expand Up @@ -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
<ui:Card WidthRequest="380" HorizontalOptions="Start">
Expand All @@ -785,6 +827,16 @@ shellui-native add card # also installs card-header, card-content, card-footer
<ui:Button Text="Deploy" />
</ui:CardFooter>
</ui:Card>

<ui:Card>
<ui:CardHeader Title="Needs review" Description="Orders waiting for approval.">
<ui:Badge Text="2" Variant="Destructive" />
<ui:CardHeader.Action>
<ui:Button Text="Export" Icon="Download" Variant="Outline" Size="Sm" />
</ui:CardHeader.Action>
</ui:CardHeader>
...
</ui:Card>
```

---
Expand Down Expand Up @@ -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
<ui:StatCard Title="Total revenue" Value="$45,231.89" Change="+20.1%" Trend="Up"
Icon="DollarSign" Description="from last month" />
<ui:StatCard Title="Last backup" Value="Backup failed" ValueTone="Destructive"
Change="Action needed" Trend="Down" />
<ui:StatCard Title="Queue" Value="{Binding Count}" IsLoading="{Binding IsBusy}" />
<ui:StatCard Title="Failed" Value="3" ValueTone="Destructive" Size="Sm" />
```

### Timeline
Expand Down Expand Up @@ -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
<ui:DataTable ItemsSource="{Binding Invoices}" ShowSelection="True" PageSize="5">
Expand All @@ -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:
Expand Down Expand Up @@ -1724,6 +1800,22 @@ shellui-native add sheet
</ui:Dialog>
```

A sheet has the same parts: `SheetHeader` (title and description stack), `SheetTitle`,
`SheetDescription` and `SheetFooter` (actions on the right), installed with `sheet`:

```xml
<ui:Sheet x:Name="InvoiceSheet">
<ui:SheetContent MaxPanelWidth="672">
<ui:SheetHeader>
<ui:SheetTitle Text="Order #1042" />
<ui:SheetDescription Text="On hold: the card was declined." />
</ui:SheetHeader>
...
<ui:SheetFooter><ui:Button Text="Retry payment" /></ui:SheetFooter>
</ui:SheetContent>
</ui:Sheet>
```

`DialogTrigger` / `DrawerTrigger` / `SheetTrigger` and `DialogClose` wrap a Button or any view.
The trigger is optional: open from code with `EditDialog.SetOpen(true)`.

Expand Down
1 change: 1 addition & 0 deletions docs/COMPONENTS_ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down
55 changes: 53 additions & 2 deletions examples/Avalonia.Demo/Components/UI/CardHeader.cs
Original file line number Diff line number Diff line change
@@ -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).
// <ui:CardHeader Title="Needs review" Description="Orders waiting for approval.">
// <ui:Badge Text="2" Variant="Destructive" />
// <ui:CardHeader.Action>
// <ui:Button Text="Download Excel" Variant="Outline" Size="Sm" />
// </ui:CardHeader.Action>
// </ui:CardHeader>
public class CardHeader : Border
{
public static readonly StyledProperty<string?> TitleProperty =
Expand All @@ -13,13 +23,19 @@ public class CardHeader : Border
public static readonly StyledProperty<string?> DescriptionProperty =
AvaloniaProperty.Register<CardHeader, string?>(nameof(Description));

public static readonly StyledProperty<Control?> ActionProperty =
AvaloniaProperty.Register<CardHeader, Control?>(nameof(Action));

private readonly TextBlock _title;
private readonly TextBlock _description;
private readonly WrapPanel _titleLine;
private readonly ContentControl _action;

static CardHeader()
{
TitleProperty.Changed.AddClassHandler<CardHeader>((h, _) => h.UpdateText());
DescriptionProperty.Changed.AddClassHandler<CardHeader>((h, _) => h.UpdateText());
ActionProperty.Changed.AddClassHandler<CardHeader>((h, _) => h.UpdateAction());
}

public string? Title
Expand All @@ -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;
}
}
11 changes: 10 additions & 1 deletion examples/Avalonia.Demo/Components/UI/DataTable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ namespace AvaloniaDemo.Components.UI;
// </ui:DataTable>
// 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<IEnumerable?> ItemsSourceProperty =
Expand Down Expand Up @@ -304,7 +306,9 @@ private void Render(IReadOnlyList<object> 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);
}

Expand Down Expand Up @@ -449,6 +453,11 @@ public class DataTableColumn
// Reads the column's value from an item; replaces Property.
public Func<object, object?>? 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<object, Control>? Cell { get; set; }

// A .NET format string for the value, e.g. "C" or "MMM d, yyyy".
public string? Format { get; set; }

Expand Down
Loading
Loading