diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md
index f070b56..ba39daa 100644
--- a/docs/COMPONENTS.md
+++ b/docs/COMPONENTS.md
@@ -141,6 +141,32 @@ shellui-native add toggle
---
+### Toggle Group
+A row of toggles that work as one control: pick one (the default) or several (`Multiple`).
+
+```bash
+shellui-native add toggle-group
+```
+
+**Properties (ToggleGroup):** `Value` (two-way; the pressed item, `null` when none), `Multiple`,
+`Values` (two-way; the pressed items in `Multiple` mode), `Variant` (Default, Outline) ·
+**Events:** `ValueChanged`, `ValuesChanged`
+
+**Properties (ToggleGroupItem):** `Value`, `Text`, `Icon`
+
+```xml
+
+
+
+
+
+```
+
+In single mode, tapping the pressed item clears the selection. To always keep one pressed, set
+`Value` back in `ValueChanged` when it arrives as `null`.
+
+---
+
### Input
Single-line text field — 40px, one themed border (the platform control's own frame is removed),
ring color + soft glow on focus, destructive border on error.
@@ -186,6 +212,41 @@ shellui-native add input-otp
---
+### Number Input
+Number field with − and + buttons. The value is clamped to `Minimum` / `Maximum`, and the buttons
+dim at the limits. Typing is committed on Enter or when the field loses focus; text that isn't a
+number puts the last value back.
+
+```bash
+shellui-native add number-input
+```
+
+**Properties:** `Value` (`decimal?`, two-way; `null` when empty), `Minimum`, `Maximum`, `Step` (1),
+`Placeholder` · **Events:** `ValueChanged`
+
+```xml
+
+```
+
+---
+
+### Tag Input
+Text field that turns entries into removable chips. Enter, a comma or a semicolon commits what was
+typed (so pasting `a, b, c` adds three tags); the × on a chip removes it.
+
+```bash
+shellui-native add tag-input # also installs wrap-layout
+```
+
+**Properties:** `Tags` (`IList`, two-way), `Placeholder`, `MaxTags` (0 = no limit),
+`AllowDuplicates` · **Events:** `TagsChanged` · **Methods:** `Add(tag)`, `Remove(tag)`
+
+```xml
+
+```
+
+---
+
### Textarea
Multi-line text field — min 80px, grows with content.
@@ -301,6 +362,32 @@ shellui-native add combobox
---
+### Multi Select
+Like Combobox, for several options: the trigger shows the picked options as removable chips, and
+the floating panel has a search field and a checked list. Picking toggles an option and leaves
+the panel open; click outside, press Escape or (on Android) go back to close it.
+
+```bash
+shellui-native add multi-select # also installs wrap-layout
+```
+
+| Property | Type | Default | Description |
+|----------|------|---------|-------------|
+| ItemsSource | IList<string> | null | Options |
+| Values | IList<string> | null | Picked options (two-way) |
+| Placeholder | string | "Select..." | Shown when nothing is picked |
+| SearchPlaceholder | string | "Search..." | Placeholder of the filter field |
+| EmptyText | string | "No results found." | Shown when nothing matches |
+
+```xml
+
+```
+
+**Events:** `ValuesChanged` · **Methods:** `Toggle(option)`
+
+---
+
### Slider
Platform slider tinted with the theme (primary range and thumb, secondary track).
@@ -386,6 +473,41 @@ shellui-native add theme-toggle
## Layout Components
+### Aspect Ratio
+Keeps its content at a fixed width / height ratio: the height follows the width.
+
+```bash
+shellui-native add aspect-ratio
+```
+
+**Properties:** `Ratio` (width divided by height; default 16:9 = 1.7778)
+
+```xml
+
+
+
+```
+
+### Wrap Layout
+`flex flex-wrap gap-2`: children flow left to right and wrap onto new rows, centered vertically
+within their row. Use it for button rows, chips and tag lists.
+
+```bash
+shellui-native add wrap-layout
+```
+
+**Properties:** `Spacing` (8, between children), `LineSpacing` (8, between rows), `LastChildFill`
+(the last child takes the rest of its row), `MinimumLastChildWidth` (96)
+
+```xml
+
+
+
+
+```
+
+Prefer it to `FlexLayout` for simple wrapping: it measures each child once per layout pass.
+
### Card
`rounded-xl border bg-card shadow-sm` container.
@@ -517,6 +639,75 @@ Trail with chevron separators; links turn foreground on hover.
---
+### Tree View
+Tree of expandable rows with selection. Tapping a row selects it and, when it has children,
+expands or collapses it. Nested `TreeViewItem`s are the children.
+
+```bash
+shellui-native add tree-view
+```
+
+**Properties (TreeView):** `SelectedValue` (two-way) · **Events:** `SelectedValueChanged`
+
+**Properties (TreeViewItem):** `Text`, `Value` (defaults to `Text`), `Icon`, `IsExpanded` (two-way)
+
+```xml
+
+
+
+
+
+
+
+
+```
+
+---
+
+### Link Card
+Tappable card with an optional icon, a title, a description and an arrow. Tapping raises
+`Clicked` and opens `Url` in the system browser when one is set.
+
+```bash
+shellui-native add link-card
+```
+
+**Properties:** `Title`, `Description`, `Icon`, `Url` · **Events:** `Clicked`
+
+```xml
+
+```
+
+---
+
+### Stepper
+Step-by-step flow: numbered circles joined by lines (the active one filled, completed ones
+checked), the active step's content, and Previous / Next / Confirm buttons. A step becomes
+clickable once it has been reached.
+
+```bash
+shellui-native add stepper
+```
+
+**Properties (Stepper):** `CurrentStep` (two-way, 0-based), `ShowNavigation` (true),
+`PreviousText`, `NextText`, `ConfirmText` · **Events:** `CurrentStepChanged`, `Confirmed` ·
+**Methods:** `Next()`, `Previous()`
+
+**Properties (StepperStep):** `Title`, `Description` · its child view is the step's content
+
+```xml
+
+
+
+
+ ...
+ ...
+
+```
+
+---
+
### Pagination
Previous / page numbers / Next, with ellipses for skipped ranges. The current page is outlined;
Previous and Next disable at the ends.
@@ -543,6 +734,63 @@ Pill label. **Variants:** Default, Secondary, Outline, Destructive, Success, War
```
+### Copy Button
+Ghost icon button that copies `Text` to the clipboard; the icon turns into a check for two
+seconds. `Label` adds text next to the icon.
+
+```bash
+shellui-native add copy-button
+```
+
+**Properties:** `Text` (what gets copied), `Label` · **Events:** `Copied` · **Methods:** `CopyAsync()`
+
+```xml
+
+
+```
+
+### Kbd
+Keyboard key hint. **Properties:** `Text`
+
+```xml
+
+```
+
+### Stat Card
+Dashboard metric: a muted title with an optional icon, the value in large bold text, a change
+pill tinted by trend, and an optional description.
+
+```bash
+shellui-native add stat-card
+```
+
+**Properties:** `Title`, `Value`, `Change`, `Trend` (Neutral, Up, Down), `Description`, `Icon`
+
+```xml
+
+```
+
+### Timeline
+Vertical list of events joined by a hairline. Each item has a marker (a dot, or a circled icon),
+a title, a time and details.
+
+```bash
+shellui-native add timeline
+```
+
+**Properties (TimelineItem):** `Title`, `Time`, `Text`, `Icon`, `IsActive` · a child view becomes
+the item's body
+
+```xml
+
+
+
+
+
+
+```
+
### Progress
8px bar; the track is the fill color at 20%. Animates to new values.
**Properties:** `Value`, `Maximum`, `Variant` (Default, Success, Warning, Destructive), `ShowLabel`
@@ -551,6 +799,60 @@ Pill label. **Variants:** Default, Secondary, Outline, Destructive, Success, War
Pulsing placeholder in the `Muted` token (opacity 1 → 0.5 → 1 every 2s, only while on screen).
**Properties:** `CornerRadius` (default 6) plus `WidthRequest` / `HeightRequest`
+### Table
+Rows inside a rounded border: a header row in the muted foreground, body rows divided by a
+hairline that tint on hover, and an optional caption.
+
+```bash
+shellui-native add table
+```
+
+**Properties (Table):** `Columns` (Grid column widths, e.g. `2*,*,*,100`), `Caption`, `Bordered`
+(true), `MinimumContentWidth` (below this width the table scrolls sideways instead of squeezing)
+
+**TableRow:** `IsSelected`, `Tapped` event · **TableHead:** `Text`, `HorizontalTextAlignment` ·
+**TableCell:** `Text`, `HorizontalTextAlignment`, `IsBold`, or any child view
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+Use star and fixed widths in `Columns`: every row is its own grid, so an `Auto` column would size
+per row. For long lists, put a `TableHeader` above a `CollectionView` whose item template is a
+`TableRow` with the same `Columns` set on the row itself.
+
+### Carousel
+One slide at a time in a rounded frame. Swipe, use the round arrow buttons or tap a dot to move.
+Each child view is a slide.
+
+```bash
+shellui-native add carousel
+```
+
+**Properties:** `Position` (two-way), `Loop` (true), `ShowArrows`, `ShowDots`, `AutoPlay`,
+`AutoPlayInterval` (ms, default 3000), `AspectRatio` (width / height, default 16:9; `0` to size it
+with `HeightRequest` instead) · **Events:** `PositionChanged` · **Methods:** `Next()`, `Previous()`
+
+```xml
+
+
+
+
+```
+
### Empty State
Placeholder for an empty list or screen: icon in a muted tile, title, description and optional
actions, centered. `Bordered="True"` adds a dashed outline.
@@ -739,6 +1041,33 @@ shellui-native add popover
```
+### Context Menu
+Right-click the trigger (long-press on touch) to open a menu at the pointer. Clicking outside,
+picking an item or pressing Escape closes it.
+
+```bash
+shellui-native add context-menu
+```
+
+**ContextMenuItem:** `Text`, `Icon`, `Shortcut` (hint text, display only), `IsDestructive`,
+`IsEnabled` · **Events:** `Clicked` · **ContextMenuSeparator** divides groups ·
+`ContextMenu.OpenAt(point)` opens it from code
+
+```xml
+
+
+ ...
+
+
+
+
+
+
+
+```
+
+On touch devices the menu opens under the trigger rather than at the finger.
+
### Tooltip
Small label shown above (or below) a view after the pointer rests on it. Pointer devices only.
@@ -782,9 +1111,8 @@ on Android close the one on top; an Alert Dialog treats that as Cancel. With not
keeps its normal behavior. Call `ShellDismiss.DismissTop()` to do the same from your own code.
### How overlays float
-`ShellPortal` (in `Shell.cs`) keeps one layer above each page's content — set up as the page
-appears, as the last child of the page's root `Grid`; a page whose root isn't a Grid gets wrapped
-in one, once. Dialogs, drawers, sheets, menus, selects, tooltips, hover cards and toasts are all
+`ShellPortal` (in `Shell.cs`) keeps one layer above each page's content. As the page appears, its
+content is wrapped in a `Grid`, once, and the layer is that Grid's last child. Dialogs, drawers, sheets, menus, selects, tooltips, hover cards and toasts are all
placed in that layer, so they are never clipped by a `ScrollView` and always draw on top. Content
moved there keeps a link to its component, so bindings and lookups keep working.
@@ -795,6 +1123,20 @@ system bars, and their content (plus popups and toasts) is kept clear of the bar
---
+## Long pages on Windows
+
+WinUI gives a window 250 layout passes to settle, and a very large page can run out and crash at
+startup with `Layout cycle detected`. The demo hit this when every component was on one page:
+WinUI's trace showed layout still reaching new elements when the passes ran out, with `Border`
+outline updates each asking for another pass. An ordinary screen is nowhere near the limit; a
+"kitchen sink" page is.
+
+If you hit it, lay out less at once: split the page, or keep sections collapsed
+(`IsVisible="False"`) until they are needed, since hidden content is not laid out. The demo does
+this, showing one category of components at a time.
+
+---
+
## Adding Components
```bash
@@ -827,6 +1169,19 @@ core helpers); components that draw icons also depend on `icon`.
| dialog | shell, dialog-trigger, dialog-content, dialog-header, dialog-footer, dialog-title, dialog-description, dialog-close |
| drawer / sheet | shell, *-trigger, *-content (content also installs icon for the close button) |
| dropdown / popover | shell, *-trigger, *-content (+ dropdown-item) |
+| table | shell |
+| carousel | shell, icon |
+| context-menu | shell, icon, element-extensions |
+| stepper | shell, icon, button |
+| toggle-group | shell, icon, element-extensions |
+| number-input, stat-card, timeline | shell, icon |
+| tag-input | shell, icon, wrap-layout |
+| kbd | shell |
+| wrap-layout | — |
+| multi-select | shell, icon, wrap-layout |
+| tree-view | shell, icon, element-extensions |
+| copy-button, link-card | shell, icon |
+| aspect-ratio | — |
| hover-card | shell, hover-card-trigger, hover-card-content |
| date-picker | shell, icon, calendar |
| tooltip | shell |
diff --git a/docs/COMPONENTS_ROADMAP.md b/docs/COMPONENTS_ROADMAP.md
index 9b76d8e..8b0553d 100644
--- a/docs/COMPONENTS_ROADMAP.md
+++ b/docs/COMPONENTS_ROADMAP.md
@@ -179,7 +179,7 @@ Icons: `python scripts/generate-icons.py` regenerates `Icon.cs` from the ShellIc
| Component | Priority | Dependencies | ShellUI Ref | Notes |
|-----------|----------|--------------|-------------|-------|
| **avatar** ✅ | P5.1 | shell, icon | Avatar | Done 2026-09-27 |
-| **table** | P5.2 | table-header, table-body, table-row, table-cell, table-head | Table | Data table |
+| **table** ✅ | P5.2 | shell | Table | Done 2026-10-05 — one template (Table, TableHeader, TableRow, TableHead, TableCell); hover, selection, caption, sideways scroll |
| **empty-state** ✅ | P5.3 | shell, icon | EmptyState | Done 2026-10-04 — icon tile, title, description, action row, optional dashed border |
| **callout** ✅ | P5.4 | shell, icon | Callout | Done 2026-10-04 — Info / Warning / Danger / Tip / Default, tinted background |
| **pagination** ✅ | P5.5 | shell, icon | Pagination | Done 2026-10-04 — previous/next, sibling window, ellipses |
@@ -190,9 +190,9 @@ Icons: `python scripts/generate-icons.py` regenerates `Icon.cs` from the ShellIc
### P6 — Lower (Advanced)
| Component | Priority | Dependencies | ShellUI Ref | Notes |
|-----------|----------|--------------|-------------|-------|
-| **context-menu** | P6.1 | context-menu-trigger, context-menu-content, context-menu-option | ContextMenu | Right-click / long-press menu |
-| **carousel** | P6.2 | carousel-content, carousel-item, carousel-previous, carousel-next, carousel-dots | Carousel | Image/content carousel |
-| **stepper** | P6.3 | stepper-list, stepper-step, stepper-content | Stepper | Step wizard |
+| **context-menu** ✅ | P6.1 | shell, icon, element-extensions | ContextMenu | Done 2026-10-05 — one template; opens at the pointer on right-click, under the trigger on long-press |
+| **carousel** ✅ | P6.2 | shell, icon | Carousel | Done 2026-10-05 — one template; swipe, arrows, dots, loop, auto-play |
+| **stepper** ✅ | P6.3 | shell, icon, button | Stepper | Done 2026-10-05 — one template (Stepper + StepperStep) with built-in navigation |
| **resizable** | P6.4 | — | Resizable | Resizable panels |
| **navbar** | P6.5 | nav-trigger, nav-content, nav-list, nav-item | Navbar | Collapsible nav bar |
| **sidebar** | P6.6 | — | Sidebar | App sidebar layout |
@@ -207,6 +207,18 @@ Icons: `python scripts/generate-icons.py` regenerates `Icon.cs` from the ShellIc
| file-upload | P7 | File picker |
| input-otp ✅ | P7 | Done 2026-10-04 — slots over one hidden field (paste / autofill / numeric keyboard) |
| date-range-picker | P7 | Date range selection |
+| toggle-group ✅ | — | Done 2026-10-05 — single or multiple selection, Default / Outline |
+| number-input ✅ | — | Done 2026-10-05 — − / + buttons, min / max clamp, decimal value |
+| tag-input ✅ | — | Done 2026-10-05 — chips with remove, Enter / comma / semicolon to commit |
+| kbd ✅ | — | Done 2026-10-05 |
+| stat-card ✅ | — | Done 2026-10-05 — title, value, trend pill, description, icon |
+| timeline ✅ | — | Done 2026-10-05 — dot or icon markers joined by a line |
+| multi-select ✅ | — | Done 2026-10-05 — searchable, chips in the trigger, panel stays open while picking |
+| tree-view ✅ | — | Done 2026-10-05 — nested items, expand / collapse, selection |
+| copy-button ✅ | P7 | Done 2026-10-05 — clipboard copy with a check confirmation |
+| link-card ✅ | — | Done 2026-10-05 — tappable card that can open a URL |
+| aspect-ratio ✅ | — | Done 2026-10-05 |
+| wrap-layout ✅ | — | Done 2026-10-05 — wrapping layout used by tag-input and the demo's button rows |
| theme-toggle | P7 | Already in demo; consider as component |
| copy-button | P7 | Copy to clipboard |
| toggle | P7 | Toggle button (vs Switch) |
diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md
index c6ad5d7..c57a4c5 100644
--- a/docs/DEVELOPMENT_PLAN.md
+++ b/docs/DEVELOPMENT_PLAN.md
@@ -16,9 +16,8 @@ Short-lived sub-branches (`feat//`) cut off the phase branch and m
into it, not directly into `main`.
```
-main ← Phase 1a merged (2026-07-05), Phase 1b merged (2026-08-29 via PR #2)
- └─ feat/p3-navigation-layout ← Phase 1c (active) — MAUI P3 tier (tabs, accordion, …)
- └─ feat/p4-overlay-portal ← Phase 1d (queued after 1c) — portal rewrite + shadcn-style controls
+main ← Phase 1a (2026-07-05), 1b (2026-08-29, PR #2), 1c + 1d (2026-10-05, PR #3) merged
+ └─ feat/p5-p6-components ← Phase 1e (active) — MAUI P5/P6 tier (table, context menu, carousel, stepper, …)
└─ feat/avalonia-implementation ← Phase 2 (planned) — Avalonia templates + reference impl
└─ feat/winui ← Phase 3 (conditional)
```
@@ -36,8 +35,8 @@ main ← Phase 1a merged (2026-07-05), Phase 1b merged (2026-08-29 via PR #2)
| Branch | Base | Status | Purpose |
|--------|------|--------|---------|
-| `main` | — | Phase 1a + 1b merged | 44 platform-keyed components, `SupportsPlatform` registry, xUnit 199/199, sizing + token contracts locked |
-| `feat/p3-navigation-layout` | `main` | **Next** | Phase 1c — MAUI P3 (tabs, accordion, collapsible, breadcrumb, scroll-area, skeleton) |
+| `main` | — | Phase 1a–1d merged | 76 platform-keyed components, theme tokens, overlay portal, xUnit 408/408 |
+| `feat/p5-p6-components` | `main` | **Active** | Phase 1e — MAUI P5/P6 (table, context-menu, carousel, stepper; 80 components, xUnit 428/428) |
---
@@ -240,7 +239,7 @@ public static class ButtonTemplate
---
-## Phase 1c — `feat/p3-navigation-layout` (**in review**)
+## Phase 1c — `feat/p3-navigation-layout` (**merged 2026-10-05 via [PR #3](https://github.com/shellui-dev/shellui-native/pull/3)**)
Finish MAUI's component vocabulary through P3 before opening the cross-platform front.
Rationale: Phase 2 (Avalonia) turns every new MAUI component into two components to
@@ -317,7 +316,7 @@ nullable `Date`/`Time` + non-generic `ItemsSource` adaptations. Proper fixes →
---
-## Phase 1d — `feat/p4-overlay-portal` (queued after Phase 1c)
+## Phase 1d — overlay portal (**merged 2026-10-05 with Phase 1c in PR #3**; the separate `feat/p4-overlay-portal` branch was never cut)
Surface polish pass driven by live testing on Windows 2026-08-30. Two distinct problems
that both need architectural fixes rather than sizing tweaks.
@@ -380,7 +379,45 @@ Date/Time pickers inside a themed border with the native frame stripped.
---
-## Phase 2 — `feat/avalonia-implementation` (queued after Phase 1d)
+## Phase 1e — `feat/p5-p6-components` (active)
+
+The remaining MAUI data-display and advanced tiers, built demo-first like Phase 1d.
+
+### Deliverables
+
+- [x] `table` — header, rows with hover / selection / tap, caption, sideways scroll below a minimum width
+- [x] `context-menu` — opens at the pointer on right-click; long-press on touch
+- [x] `carousel` — swipe, arrows, dots, loop, auto-play
+- [x] `stepper` — numbered steps with completed / active states and built-in navigation
+- [x] Android pass for the four above plus combobox, time picker, callout and back-to-close
+ (2026-10-05, Pixel 7 / API 34, light and dark): fixed popup top padding, context-menu long-press
+ and toast offset
+- [x] `toggle-group`, `number-input`, `tag-input`, `kbd`, `stat-card`, `timeline`, `wrap-layout`
+ (Windows only so far)
+- [x] Icon generator converts arcs to Bezier curves — MAUI on Windows failed to draw the small
+ arcs in `activity`, which crashed the app
+- [x] Demo shows one category at a time (a single page of every component exceeded WinUI's
+ layout-pass limit; see COMPONENTS.md, "Long pages on Windows")
+- [x] Android pass for the second batch (2026-10-05): fixed the overlay layer for a Grid page root
+ and the tag-input keyboard / height
+- [x] `multi-select`, `tree-view`, `copy-button`, `link-card`, `aspect-ratio` — run on Windows and
+ Android
+- [ ] iOS / Mac Catalyst pass for everything
+- [ ] Escape closes the overlay on top on Mac Catalyst
+- [ ] `resizable`, `navbar`, `sidebar` (P6.4–P6.6)
+
+Each family ships as one template (e.g. `table` holds Table, TableHeader, TableRow, TableHead
+and TableCell) rather than one template per part.
+
+### Exit criteria
+
+1. Every new component clicked through in `MAUI.Demo` on Windows and on an Android device
+2. `dotnet build` clean for the Windows and Android targets, `dotnet test` green
+3. Docs and roadmap updated
+
+---
+
+## Phase 2 — `feat/avalonia-implementation` (queued after Phase 1e)
Cross-desktop (Windows + macOS + Linux) from one XAML codebase.
diff --git a/examples/MAUI.Demo/Components/UI/AspectRatio.cs b/examples/MAUI.Demo/Components/UI/AspectRatio.cs
new file mode 100644
index 0000000..039b02f
--- /dev/null
+++ b/examples/MAUI.Demo/Components/UI/AspectRatio.cs
@@ -0,0 +1,33 @@
+namespace MAUI.Demo.Components.UI;
+
+// Keeps its content at a fixed width / height ratio: the height follows the width.
+//
+//
+//
+public partial class AspectRatio : ContentView
+{
+ // Width divided by height. 16:9 is 1.7778, 4:3 is 1.3333, a square is 1.
+ public static readonly BindableProperty RatioProperty =
+ BindableProperty.Create(nameof(Ratio), typeof(double), typeof(AspectRatio), 16d / 9d,
+ propertyChanged: (b, o, n) => ((AspectRatio)b).Fit());
+
+ public double Ratio
+ {
+ get => (double)GetValue(RatioProperty);
+ set => SetValue(RatioProperty, value);
+ }
+
+ public AspectRatio()
+ {
+ IsClippedToBounds = true;
+ SizeChanged += (_, _) => Fit();
+ }
+
+ private void Fit()
+ {
+ if (Width <= 0 || Ratio <= 0) return;
+ var height = Width / Ratio;
+ // Only when it actually changes: setting it again would ask for another layout pass.
+ if (Math.Abs(HeightRequest - height) > 0.5) HeightRequest = height;
+ }
+}
diff --git a/examples/MAUI.Demo/Components/UI/Carousel.cs b/examples/MAUI.Demo/Components/UI/Carousel.cs
new file mode 100644
index 0000000..ad354e1
--- /dev/null
+++ b/examples/MAUI.Demo/Components/UI/Carousel.cs
@@ -0,0 +1,302 @@
+using Microsoft.Maui.Controls.Shapes;
+
+namespace MAUI.Demo.Components.UI;
+
+// Carousel — one slide at a time in a rounded-lg frame; swipe, use the round arrow buttons or
+// tap a dot to move. Each child view is a slide.
+//
+//
+//
+//
+[ContentProperty(nameof(Slides))]
+public partial class Carousel : ContentView
+{
+ public static readonly BindableProperty PositionProperty =
+ BindableProperty.Create(nameof(Position), typeof(int), typeof(Carousel), 0, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((Carousel)b).OnPositionChanged());
+
+ // At the last slide, Next wraps to the first (and Previous the other way).
+ public static readonly BindableProperty LoopProperty =
+ BindableProperty.Create(nameof(Loop), typeof(bool), typeof(Carousel), true,
+ propertyChanged: (b, o, n) => ((Carousel)b).UpdateControls());
+
+ public static readonly BindableProperty ShowArrowsProperty =
+ BindableProperty.Create(nameof(ShowArrows), typeof(bool), typeof(Carousel), true,
+ propertyChanged: (b, o, n) => ((Carousel)b).UpdateControls());
+
+ public static readonly BindableProperty ShowDotsProperty =
+ BindableProperty.Create(nameof(ShowDots), typeof(bool), typeof(Carousel), true,
+ propertyChanged: (b, o, n) => ((Carousel)b).UpdateControls());
+
+ public static readonly BindableProperty AutoPlayProperty =
+ BindableProperty.Create(nameof(AutoPlay), typeof(bool), typeof(Carousel), false,
+ propertyChanged: (b, o, n) => ((Carousel)b).UpdateTimer());
+
+ // Milliseconds between slides while AutoPlay is on.
+ public static readonly BindableProperty AutoPlayIntervalProperty =
+ BindableProperty.Create(nameof(AutoPlayInterval), typeof(int), typeof(Carousel), 3000,
+ propertyChanged: (b, o, n) => ((Carousel)b).UpdateTimer());
+
+ // Width / height of the frame. 0 turns it off: set HeightRequest on the carousel instead.
+ public static readonly BindableProperty AspectRatioProperty =
+ BindableProperty.Create(nameof(AspectRatio), typeof(double), typeof(Carousel), 16d / 9d,
+ propertyChanged: (b, o, n) => ((Carousel)b).Resize());
+
+ public int Position
+ {
+ get => (int)GetValue(PositionProperty);
+ set => SetValue(PositionProperty, value);
+ }
+
+ public bool Loop
+ {
+ get => (bool)GetValue(LoopProperty);
+ set => SetValue(LoopProperty, value);
+ }
+
+ public bool ShowArrows
+ {
+ get => (bool)GetValue(ShowArrowsProperty);
+ set => SetValue(ShowArrowsProperty, value);
+ }
+
+ public bool ShowDots
+ {
+ get => (bool)GetValue(ShowDotsProperty);
+ set => SetValue(ShowDotsProperty, value);
+ }
+
+ public bool AutoPlay
+ {
+ get => (bool)GetValue(AutoPlayProperty);
+ set => SetValue(AutoPlayProperty, value);
+ }
+
+ public int AutoPlayInterval
+ {
+ get => (int)GetValue(AutoPlayIntervalProperty);
+ set => SetValue(AutoPlayIntervalProperty, value);
+ }
+
+ public double AspectRatio
+ {
+ get => (double)GetValue(AspectRatioProperty);
+ set => SetValue(AspectRatioProperty, value);
+ }
+
+ public IList Slides => _track.Children;
+
+ public event EventHandler? PositionChanged;
+
+ private const string SlideAnimation = "CarouselSlide";
+
+ private readonly Grid _track;
+ private readonly Border _frame;
+ private readonly Border _previous;
+ private readonly Border _next;
+ private readonly HorizontalStackLayout _dots;
+ private IDispatcherTimer? _timer;
+ // Where the track is, in slides: 1.5 is halfway between the second and third slide.
+ private double _offset;
+ private double _dragX;
+
+ public Carousel()
+ {
+ _track = new Grid { IsClippedToBounds = true, BackgroundColor = Colors.Transparent };
+ _track.ChildAdded += (_, _) => OnSlidesChanged();
+ _track.ChildRemoved += (_, _) => OnSlidesChanged();
+ var pan = new PanGestureRecognizer();
+ pan.PanUpdated += OnPan;
+ _track.GestureRecognizers.Add(pan);
+
+ _frame = new Border
+ {
+ Content = _track,
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusLg },
+ BackgroundColor = Colors.Transparent
+ };
+
+ _previous = Arrow(IconName.ChevronLeft, LayoutOptions.Start, "Previous slide", Previous);
+ _next = Arrow(IconName.ChevronRight, LayoutOptions.End, "Next slide", Next);
+ _dots = new HorizontalStackLayout
+ {
+ Spacing = 0,
+ HorizontalOptions = LayoutOptions.Center,
+ VerticalOptions = LayoutOptions.End,
+ Margin = new Thickness(0, 0, 0, 8)
+ };
+
+ Content = new Grid { Children = { _frame, _previous, _next, _dots } };
+ SizeChanged += (_, _) => Resize();
+ Loaded += (_, _) => UpdateTimer();
+ Unloaded += (_, _) => StopTimer();
+ UpdateControls();
+ }
+
+ private int Count => _track.Children.Count;
+ private int Current => Math.Clamp(Position, 0, Math.Max(0, Count - 1));
+
+ public void Next()
+ {
+ if (Current < Count - 1) Position = Current + 1;
+ else if (Loop && Count > 1) Position = 0;
+ }
+
+ public void Previous()
+ {
+ if (Current > 0) Position = Current - 1;
+ else if (Loop && Count > 1) Position = Count - 1;
+ }
+
+ private Border Arrow(IconName icon, LayoutOptions side, string description, Action press)
+ {
+ var button = new Border
+ {
+ Content = new Icon { Name = icon, Size = 16, Token = ShellToken.Foreground },
+ WidthRequest = 32,
+ HeightRequest = 32,
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = 16 },
+ HorizontalOptions = side,
+ VerticalOptions = LayoutOptions.Center,
+ Margin = new Thickness(12, 0),
+ Shadow = new Shadow { Brush = new SolidColorBrush(Colors.Black), Offset = new Point(0, 2), Radius = 6, Opacity = 0.15f }
+ };
+ button.Token(VisualElement.BackgroundColorProperty, ShellToken.Background);
+ button.Token(Border.StrokeProperty, ShellToken.Border);
+ SemanticProperties.SetDescription(button, description);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => press();
+ button.GestureRecognizers.Add(tap);
+ return button;
+ }
+
+ private void OnSlidesChanged()
+ {
+ _dots.Children.Clear();
+ for (var i = 0; i < Count; i++)
+ {
+ var index = i;
+ var dot = new Border
+ {
+ WidthRequest = 8,
+ HeightRequest = 8,
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = 4 },
+ HorizontalOptions = LayoutOptions.Center,
+ VerticalOptions = LayoutOptions.Center
+ };
+ dot.Token(VisualElement.BackgroundColorProperty, ShellToken.Primary);
+ // 8px dot in a 16px tap target.
+ var target = new Grid { WidthRequest = 16, HeightRequest = 16, BackgroundColor = Colors.Transparent, Children = { dot } };
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => Position = index;
+ target.GestureRecognizers.Add(tap);
+ _dots.Children.Add(target);
+ }
+ _offset = Current;
+ Render();
+ UpdateControls();
+ }
+
+ private void OnPositionChanged()
+ {
+ UpdateControls();
+ this.AbortAnimation(SlideAnimation);
+ new Animation(v => { _offset = v; Render(); }, _offset, Current)
+ .Commit(this, SlideAnimation, 16, 300, Easing.CubicOut);
+ PositionChanged?.Invoke(this, Current);
+ // A manual move restarts the auto-play countdown.
+ UpdateTimer();
+ }
+
+ private void Resize()
+ {
+ if (AspectRatio > 0 && Width > 0) _frame.HeightRequest = Width / AspectRatio;
+ else if (AspectRatio <= 0) _frame.HeightRequest = -1;
+ Render();
+ }
+
+ // Slides are stacked in one cell and spread out sideways by translation.
+ private void Render()
+ {
+ var width = _track.Width > 0 ? _track.Width : Width;
+ if (width <= 0) return;
+ for (var i = 0; i < Count; i++)
+ {
+ if (_track.Children[i] is View slide)
+ slide.TranslationX = (i - _offset) * width;
+ }
+ }
+
+ private void UpdateControls()
+ {
+ var current = Current;
+ var many = Count > 1;
+ _previous.IsVisible = ShowArrows && many;
+ _next.IsVisible = ShowArrows && many;
+ _previous.Opacity = Loop || current > 0 ? 0.9 : 0.4;
+ _next.Opacity = Loop || current < Count - 1 ? 0.9 : 0.4;
+ _dots.IsVisible = ShowDots && many;
+ for (var i = 0; i < _dots.Children.Count; i++)
+ {
+ // One color at two strengths, so the dots stay readable over any slide.
+ if (_dots.Children[i] is Grid { Children: [Border dot] })
+ dot.Opacity = i == current ? 1 : 0.3;
+ }
+ }
+
+ private void OnPan(object? sender, PanUpdatedEventArgs e)
+ {
+ var width = _track.Width;
+ if (width <= 0 || Count < 2) return;
+ switch (e.StatusType)
+ {
+ case GestureStatus.Started:
+ this.AbortAnimation(SlideAnimation);
+ _dragX = 0;
+ break;
+ case GestureStatus.Running:
+ // Follow the finger, with a little give past the first and last slide.
+ _dragX = e.TotalX;
+ _offset = Math.Clamp(Current - _dragX / width, -0.25, Count - 0.75);
+ Render();
+ break;
+ case GestureStatus.Completed:
+ case GestureStatus.Canceled:
+ var moved = _dragX / width;
+ _dragX = 0;
+ var current = Current;
+ if (moved <= -0.2 && current < Count - 1) Position = current + 1;
+ else if (moved >= 0.2 && current > 0) Position = current - 1;
+ else
+ {
+ // Not far enough: settle back.
+ new Animation(v => { _offset = v; Render(); }, _offset, current)
+ .Commit(this, SlideAnimation, 16, 200, Easing.CubicOut);
+ }
+ break;
+ }
+ }
+
+ private void UpdateTimer()
+ {
+ StopTimer();
+ if (!AutoPlay || Count < 2 || Handler is null) return;
+ _timer = Dispatcher.CreateTimer();
+ _timer.Interval = TimeSpan.FromMilliseconds(Math.Max(500, AutoPlayInterval));
+ _timer.Tick += (_, _) =>
+ {
+ if (Current < Count - 1) Position = Current + 1;
+ else Position = 0;
+ };
+ _timer.Start();
+ }
+
+ private void StopTimer()
+ {
+ _timer?.Stop();
+ _timer = null;
+ }
+}
diff --git a/examples/MAUI.Demo/Components/UI/ContextMenu.cs b/examples/MAUI.Demo/Components/UI/ContextMenu.cs
new file mode 100644
index 0000000..c27ec3b
--- /dev/null
+++ b/examples/MAUI.Demo/Components/UI/ContextMenu.cs
@@ -0,0 +1,244 @@
+using Microsoft.Maui.Controls.Shapes;
+
+namespace MAUI.Demo.Components.UI;
+
+// Context menu — right-click (long-press on touch) the trigger to open a menu at the pointer.
+// Clicking outside, picking an item or pressing Escape closes it.
+//
+// ...any view...
+//
+//
+//
+//
+//
+//
+public partial class ContextMenu : ShellPopoverHost
+{
+ private Point? _point;
+
+ public ContextMenu()
+ {
+ // The trigger usually is a surface (a card, a row): let it fill its container.
+ HorizontalOptions = LayoutOptions.Fill;
+ }
+
+ protected override bool IsContent(Element child) => child is ContextMenuContent;
+ protected override Point? AnchorPoint => _point;
+ protected override double Offset => _point is null ? 4 : 0;
+
+ // Opens the menu at a point relative to this view, or below the trigger when null.
+ public void OpenAt(Point? point)
+ {
+ if (IsOpen) IsOpen = false;
+ _point = point;
+ IsOpen = true;
+ }
+}
+
+// The surface that opens the enclosing ContextMenu on right-click / long-press.
+public partial class ContextMenuTrigger : ContentView
+{
+#if ANDROID || IOS || MACCATALYST
+ private object? _hooked;
+#endif
+
+ public ContextMenuTrigger()
+ {
+ var secondary = new TapGestureRecognizer { Buttons = ButtonsMask.Secondary };
+ secondary.Tapped += (_, e) =>
+ {
+ var menu = this.FindParentOfType();
+ menu?.OpenAt(e.GetPosition(menu));
+ };
+ GestureRecognizers.Add(secondary);
+ HandlerChanged += (_, _) => HookLongPress();
+ }
+
+ private void Open(Point? point) => this.FindParentOfType()?.OpenAt(point);
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName != nameof(Content)) return;
+ if (Content is View content) content.HandlerChanged += (_, _) => HookLongPress();
+ HookLongPress();
+ }
+
+ private void HookLongPress()
+ {
+#if ANDROID
+ // Listen on the content, not on this view: this view's gesture handling takes the touch
+ // stream before Android's own long-press detection would see it.
+ var native = (Content as View)?.Handler?.PlatformView as Android.Views.View
+ ?? Handler?.PlatformView as Android.Views.View;
+ if (native is null || ReferenceEquals(native, _hooked)) return;
+ _hooked = native;
+ native.LongClickable = true;
+ native.LongClick += (_, e) => { e.Handled = true; Open(null); };
+#elif IOS || MACCATALYST
+ if (Handler?.PlatformView is not UIKit.UIView native || ReferenceEquals(native, _hooked)) return;
+ _hooked = native;
+ native.AddGestureRecognizer(new UIKit.UILongPressGestureRecognizer(gesture =>
+ {
+ if (gesture.State == UIKit.UIGestureRecognizerState.Began) Open(null);
+ }));
+#endif
+ }
+}
+
+// Menu panel — min-w-[13rem] rounded-md border bg-popover p-1 shadow-md.
+[ContentProperty(nameof(Children))]
+public partial class ContextMenuContent : ContentView
+{
+ private readonly VerticalStackLayout _stack;
+
+ public new IList Children => _stack.Children;
+
+ public ContextMenuContent()
+ {
+ _stack = new VerticalStackLayout { Spacing = 0 };
+ var panel = new Border
+ {
+ Content = _stack,
+ Padding = new Thickness(4),
+ MinimumWidthRequest = 208,
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ Shadow = ShellPopups.PanelShadow()
+ };
+ panel.Token(VisualElement.BackgroundColorProperty, ShellToken.Popover);
+ panel.Token(Border.StrokeProperty, ShellToken.Border);
+ // Taps on the panel padding must not fall through to the click-outside catcher.
+ panel.GestureRecognizers.Add(new TapGestureRecognizer());
+ Content = panel;
+ }
+}
+
+// Menu row — rounded-sm px-2 py-1.5 text-sm, hover:bg-accent, optional icon and shortcut hint.
+public partial class ContextMenuItem : ContentView
+{
+ public static readonly BindableProperty TextProperty =
+ BindableProperty.Create(nameof(Text), typeof(string), typeof(ContextMenuItem), string.Empty,
+ propertyChanged: (b, o, n) => ((ContextMenuItem)b).Update());
+
+ public static readonly BindableProperty IconProperty =
+ BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(ContextMenuItem), IconName.None,
+ propertyChanged: (b, o, n) => ((ContextMenuItem)b).Update());
+
+ // Keyboard hint shown at the end of the row (display only).
+ public static readonly BindableProperty ShortcutProperty =
+ BindableProperty.Create(nameof(Shortcut), typeof(string), typeof(ContextMenuItem), string.Empty,
+ propertyChanged: (b, o, n) => ((ContextMenuItem)b).Update());
+
+ public static readonly BindableProperty IsDestructiveProperty =
+ BindableProperty.Create(nameof(IsDestructive), typeof(bool), typeof(ContextMenuItem), false,
+ propertyChanged: (b, o, n) => ((ContextMenuItem)b).Update());
+
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => (IconName)GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public string Shortcut
+ {
+ get => (string)GetValue(ShortcutProperty);
+ set => SetValue(ShortcutProperty, value);
+ }
+
+ public bool IsDestructive
+ {
+ get => (bool)GetValue(IsDestructiveProperty);
+ set => SetValue(IsDestructiveProperty, value);
+ }
+
+ public event EventHandler? Clicked;
+
+ private readonly Border _row;
+ private readonly Icon _icon;
+ private readonly Label _label;
+ private readonly Label _shortcut;
+
+ public ContextMenuItem()
+ {
+ _icon = new Icon { Size = 16, IsVisible = false, VerticalOptions = LayoutOptions.Center };
+ _label = new Label { FontSize = 14, VerticalOptions = LayoutOptions.Center, VerticalTextAlignment = TextAlignment.Center };
+ _shortcut = new Label { FontSize = 12, VerticalOptions = LayoutOptions.Center, VerticalTextAlignment = TextAlignment.Center, IsVisible = false };
+ _shortcut.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+
+ var grid = new Grid
+ {
+ ColumnSpacing = 8,
+ ColumnDefinitions =
+ {
+ new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto)
+ }
+ };
+ grid.Add(_icon, 0, 0);
+ grid.Add(_label, 1, 0);
+ grid.Add(_shortcut, 2, 0);
+
+ _row = new Border
+ {
+ Content = grid,
+ HeightRequest = 32,
+ Padding = new Thickness(8, 0),
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusSm },
+ BackgroundColor = Colors.Transparent
+ };
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => { if (IsEnabled) _row.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent); };
+ pointer.PointerExited += (_, _) => { _row.ClearValue(VisualElement.BackgroundColorProperty); _row.BackgroundColor = Colors.Transparent; };
+ _row.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => Press();
+ _row.GestureRecognizers.Add(tap);
+
+ Content = _row;
+ Update();
+ }
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == nameof(IsEnabled)) Opacity = IsEnabled ? 1 : 0.5;
+ }
+
+ private void Press()
+ {
+ if (!IsEnabled) return;
+ this.FindParentOfType()?.Close();
+ Clicked?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void Update()
+ {
+ var color = IsDestructive ? ShellToken.Destructive : ShellToken.PopoverForeground;
+ _icon.Name = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ _icon.Token = color;
+ _label.Text = Text ?? string.Empty;
+ _label.Token(Label.TextColorProperty, color);
+ _shortcut.Text = Shortcut ?? string.Empty;
+ _shortcut.IsVisible = !string.IsNullOrEmpty(Shortcut);
+ }
+}
+
+// Divider between groups of items — -mx-1 my-1 h-px bg-border.
+public partial class ContextMenuSeparator : ContentView
+{
+ public ContextMenuSeparator()
+ {
+ var line = new BoxView { HeightRequest = 1, BackgroundColor = Colors.Transparent };
+ line.Token(BoxView.ColorProperty, ShellToken.Border);
+ Margin = new Thickness(-4, 4);
+ Content = line;
+ }
+}
diff --git a/examples/MAUI.Demo/Components/UI/CopyButton.cs b/examples/MAUI.Demo/Components/UI/CopyButton.cs
new file mode 100644
index 0000000..00224ff
--- /dev/null
+++ b/examples/MAUI.Demo/Components/UI/CopyButton.cs
@@ -0,0 +1,132 @@
+using Microsoft.Maui.Controls.Shapes;
+
+namespace MAUI.Demo.Components.UI;
+
+// Copies Text to the clipboard — a ghost icon button (h-9 w-9 rounded-md, hover:bg-accent) whose
+// icon turns into a check for two seconds after copying. Label adds text next to the icon.
+// Usage:
+public partial class CopyButton : ContentView
+{
+ public static readonly BindableProperty TextProperty =
+ BindableProperty.Create(nameof(Text), typeof(string), typeof(CopyButton), string.Empty,
+ propertyChanged: (b, o, n) => ((CopyButton)b).Update());
+
+ public static readonly BindableProperty LabelProperty =
+ BindableProperty.Create(nameof(Label), typeof(string), typeof(CopyButton), string.Empty,
+ propertyChanged: (b, o, n) => ((CopyButton)b).Update());
+
+ // The text placed on the clipboard.
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public string Label
+ {
+ get => (string)GetValue(LabelProperty);
+ set => SetValue(LabelProperty, value);
+ }
+
+ public event EventHandler? Copied;
+
+ private readonly Border _border;
+ private readonly Icon _icon;
+ private readonly Label _label;
+ private bool _copied;
+ private bool _hovered;
+ private int _version;
+
+ public CopyButton()
+ {
+ _icon = new Icon { Name = IconName.Copy, Size = 16 };
+ _label = new Label
+ {
+ FontSize = 14,
+ FontAttributes = FontAttributes.Bold,
+ VerticalTextAlignment = TextAlignment.Center,
+ LineBreakMode = LineBreakMode.NoWrap,
+ IsVisible = false
+ };
+ _border = new Border
+ {
+ Content = new HorizontalStackLayout
+ {
+ Spacing = 8,
+ HorizontalOptions = LayoutOptions.Center,
+ VerticalOptions = LayoutOptions.Center,
+ Children = { _icon, _label }
+ },
+ HeightRequest = 36,
+ MinimumWidthRequest = 36,
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ BackgroundColor = Colors.Transparent
+ };
+
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => { _hovered = true; Update(); };
+ pointer.PointerExited += (_, _) => { _hovered = false; Update(); };
+ _border.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { ShellFocus.FocusPressed(this); _ = CopyAsync(); };
+ _border.GestureRecognizers.Add(tap);
+ ShellFocus.MakeFocusable(this, () => _ = CopyAsync());
+
+ Content = _border;
+ HorizontalOptions = LayoutOptions.Start;
+ Update();
+ }
+
+ public async Task CopyAsync()
+ {
+ if (!IsEnabled || string.IsNullOrEmpty(Text)) return;
+ try
+ {
+ await Clipboard.Default.SetTextAsync(Text);
+ }
+ catch (Exception)
+ {
+ // No clipboard access (e.g. a locked-down device): leave the icon as it is.
+ return;
+ }
+
+ var version = ++_version;
+ _copied = true;
+ Update();
+ Copied?.Invoke(this, EventArgs.Empty);
+ await Task.Delay(2000);
+ if (version != _version) return;
+ _copied = false;
+ Update();
+ }
+
+ private void Update()
+ {
+ var hasLabel = !string.IsNullOrEmpty(Label);
+ _label.Text = Label ?? string.Empty;
+ _label.IsVisible = hasLabel;
+ _border.Padding = new Thickness(hasLabel ? 12 : 0, 0);
+
+ _icon.Name = _copied ? IconName.Check : IconName.Copy;
+ var foreground = _copied ? ShellToken.Success : _hovered ? ShellToken.AccentForeground : ShellToken.Foreground;
+ _icon.Token = foreground;
+ _label.Token(Microsoft.Maui.Controls.Label.TextColorProperty, _hovered ? ShellToken.AccentForeground : ShellToken.Foreground);
+
+ if (_hovered && IsEnabled)
+ _border.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent);
+ else
+ {
+ _border.ClearValue(VisualElement.BackgroundColorProperty);
+ _border.BackgroundColor = Colors.Transparent;
+ }
+ Opacity = IsEnabled && !string.IsNullOrEmpty(Text) ? 1.0 : 0.5;
+ SemanticProperties.SetDescription(this, _copied ? "Copied" : "Copy to clipboard");
+ }
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == IsEnabledProperty.PropertyName && _border != null) Update();
+ }
+}
diff --git a/examples/MAUI.Demo/Components/UI/Icon.cs b/examples/MAUI.Demo/Components/UI/Icon.cs
index 19adc81..e127a6f 100644
--- a/examples/MAUI.Demo/Components/UI/Icon.cs
+++ b/examples/MAUI.Demo/Components/UI/Icon.cs
@@ -131,30 +131,30 @@ public static class IconPaths
IconName.X => "M 18 6 L 6 18 M 6 6 L 18 18",
IconName.Plus => "M 5 12 L 19 12 M 12 5 L 12 19",
IconName.Minus => "M 5 12 L 19 12",
- IconName.Search => "M 19 11 A 8 8 0 1 1 3 11 A 8 8 0 1 1 19 11 Z M 21 21 L 16.7 16.7",
+ IconName.Search => "M 19 11 C 19 15.418 15.418 19 11 19 C 6.582 19 3 15.418 3 11 C 3 6.582 6.582 3 11 3 C 15.418 3 19 6.582 19 11 Z M 21 21 L 16.7 16.7",
IconName.Filter => "M 22 3 L 2 3 L 10 12.46 L 10 19 L 14 21 L 14 12.46 L 22 3 Z",
- IconName.Settings => "M 12.22 2 L 11.78 2 A 2 2 0 0 0 9.78 4 L 9.78 4.18 A 2 2 0 0 1 8.78 5.91 L 8.35 6.16 A 2 2 0 0 1 6.35 6.16 L 6.2 6.08 A 2 2 0 0 0 3.47 6.81 L 3.25 7.19 A 2 2 0 0 0 3.98 9.92 L 4.13 10.02 A 2 2 0 0 1 5.13 11.74 L 5.13 12.25 A 2 2 0 0 1 4.13 13.99 L 3.98 14.08 A 2 2 0 0 0 3.25 16.81 L 3.47 17.19 A 2 2 0 0 0 6.2 17.92 L 6.35 17.84 A 2 2 0 0 1 8.35 17.84 L 8.78 18.09 A 2 2 0 0 1 9.78 19.82 L 9.78 20 A 2 2 0 0 0 11.78 22 L 12.22 22 A 2 2 0 0 0 14.22 20 L 14.22 19.82 A 2 2 0 0 1 15.22 18.09 L 15.65 17.84 A 2 2 0 0 1 17.65 17.84 L 17.8 17.92 A 2 2 0 0 0 20.53 17.19 L 20.75 16.8 A 2 2 0 0 0 20.02 14.07 L 19.87 13.99 A 2 2 0 0 1 18.87 12.25 L 18.87 11.75 A 2 2 0 0 1 19.87 10.01 L 20.02 9.92 A 2 2 0 0 0 20.75 7.19 L 20.53 6.81 A 2 2 0 0 0 17.8 6.08 L 17.65 6.16 A 2 2 0 0 1 15.65 6.16 L 15.22 5.91 A 2 2 0 0 1 14.22 4.18 L 14.22 4 A 2 2 0 0 0 12.22 2 Z M 15 12 A 3 3 0 1 1 9 12 A 3 3 0 1 1 15 12 Z",
+ IconName.Settings => "M 12.22 2 L 11.78 2 C 10.675 2 9.78 2.895 9.78 4 L 9.78 4.18 C 9.779 4.894 9.398 5.553 8.78 5.91 L 8.35 6.16 C 7.731 6.517 6.969 6.517 6.35 6.16 L 6.2 6.08 C 5.244 5.529 4.023 5.855 3.47 6.81 L 3.25 7.19 C 2.699 8.146 3.025 9.367 3.98 9.92 L 4.13 10.02 C 4.745 10.375 5.126 11.03 5.13 11.74 L 5.13 12.25 C 5.133 12.967 4.751 13.631 4.13 13.99 L 3.98 14.08 C 3.025 14.633 2.699 15.854 3.25 16.81 L 3.47 17.19 C 4.023 18.145 5.244 18.471 6.2 17.92 L 6.35 17.84 C 6.969 17.483 7.731 17.483 8.35 17.84 L 8.78 18.09 C 9.398 18.447 9.779 19.106 9.78 19.82 L 9.78 20 C 9.78 21.105 10.675 22 11.78 22 L 12.22 22 C 13.325 22 14.22 21.105 14.22 20 L 14.22 19.82 C 14.221 19.106 14.602 18.447 15.22 18.09 L 15.65 17.84 C 16.269 17.483 17.031 17.483 17.65 17.84 L 17.8 17.92 C 18.756 18.471 19.977 18.145 20.53 17.19 L 20.75 16.8 C 21.301 15.844 20.975 14.623 20.02 14.07 L 19.87 13.99 C 19.249 13.631 18.867 12.967 18.87 12.25 L 18.87 11.75 C 18.867 11.033 19.249 10.369 19.87 10.01 L 20.02 9.92 C 20.975 9.367 21.301 8.146 20.75 7.19 L 20.53 6.81 C 19.977 5.855 18.756 5.529 17.8 6.08 L 17.65 6.16 C 17.031 6.517 16.269 6.517 15.65 6.16 L 15.22 5.91 C 14.602 5.553 14.221 4.894 14.22 4.18 L 14.22 4 C 14.22 2.895 13.325 2 12.22 2 Z 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.SlidersHorizontal => "M 21 4 L 14 4 M 10 4 L 3 4 M 21 12 L 12 12 M 8 12 L 3 12 M 21 20 L 16 20 M 12 20 L 3 20 M 14 2 L 14 6 M 8 10 L 8 14 M 16 18 L 16 22",
- IconName.Pencil => "M 21.174 6.812 A 1 1 0 0 0 17.188 2.825 L 3.842 16.174 A 2 2 0 0 0 3.342 17.004 L 2.021 21.356 A 0.5 0.5 0 0 0 2.644 21.978 L 6.997 20.658 A 2 2 0 0 0 7.827 20.161 Z M 15 5 L 19 9",
+ IconName.Pencil => "M 21.174 6.812 C 22.275 5.711 22.275 3.926 21.174 2.826 C 20.074 1.725 18.289 1.724 17.188 2.825 L 3.842 16.174 C 3.61 16.406 3.438 16.691 3.342 17.004 L 2.021 21.356 C 1.968 21.532 2.017 21.723 2.147 21.853 C 2.277 21.983 2.468 22.031 2.644 21.978 L 6.997 20.658 C 7.31 20.563 7.595 20.392 7.827 20.161 Z M 15 5 L 19 9",
IconName.Trash => "M 3 6 L 21 6 M 19 6 L 19 20 C 19 21 18 22 17 22 L 7 22 C 6 22 5 21 5 20 L 5 6 M 8 6 L 8 4 C 8 3 9 2 10 2 L 14 2 C 15 2 16 3 16 4 L 16 6",
IconName.Trash2 => "M 3 6 L 21 6 M 19 6 L 19 20 C 19 21 18 22 17 22 L 7 22 C 6 22 5 21 5 20 L 5 6 M 8 6 L 8 4 C 8 3 9 2 10 2 L 14 2 C 15 2 16 3 16 4 L 16 6 M 10 11 L 10 17 M 14 11 L 14 17",
- IconName.Copy => "M 10 8 L 20 8 A 2 2 0 0 1 22 10 L 22 20 A 2 2 0 0 1 20 22 L 10 22 A 2 2 0 0 1 8 20 L 8 10 A 2 2 0 0 1 10 8 Z M 4 16 C 2.9 16 2 15.1 2 14 L 2 4 C 2 2.9 2.9 2 4 2 L 14 2 C 15.1 2 16 2.9 16 4",
- IconName.Clipboard => "M 9 2 L 15 2 A 1 1 0 0 1 16 3 L 16 5 A 1 1 0 0 1 15 6 L 9 6 A 1 1 0 0 1 8 5 L 8 3 A 1 1 0 0 1 9 2 Z M 16 4 L 18 4 A 2 2 0 0 1 20 6 L 20 20 A 2 2 0 0 1 18 22 L 6 22 A 2 2 0 0 1 4 20 L 4 6 A 2 2 0 0 1 6 4 L 8 4",
- IconName.Save => "M 15.2 3 A 2 2 0 0 1 16.6 3.6 L 20.4 7.4 A 2 2 0 0 1 21 8.8 L 21 19 A 2 2 0 0 1 19 21 L 5 21 A 2 2 0 0 1 3 19 L 3 5 A 2 2 0 0 1 5 3 Z M 17 21 L 17 14 A 1 1 0 0 0 16 13 L 8 13 A 1 1 0 0 0 7 14 L 7 21 M 7 3 L 7 7 A 1 1 0 0 0 8 8 L 15 8",
- IconName.Download => "M 21 15 L 21 19 A 2 2 0 0 1 19 21 L 5 21 A 2 2 0 0 1 3 19 L 3 15 M 7 10 L 12 15 L 17 10 M 12 15 L 12 3",
- IconName.Upload => "M 21 15 L 21 19 A 2 2 0 0 1 19 21 L 5 21 A 2 2 0 0 1 3 19 L 3 15 M 17 8 L 12 3 L 7 8 M 12 3 L 12 15",
- IconName.Share => "M 4 12 L 4 20 A 2 2 0 0 0 6 22 L 18 22 A 2 2 0 0 0 20 20 L 20 12 M 16 6 L 12 2 L 8 6 M 12 2 L 12 15",
- IconName.Share2 => "M 21 5 A 3 3 0 1 1 15 5 A 3 3 0 1 1 21 5 Z M 9 12 A 3 3 0 1 1 3 12 A 3 3 0 1 1 9 12 Z M 21 19 A 3 3 0 1 1 15 19 A 3 3 0 1 1 21 19 Z M 8.59 13.51 L 15.42 17.49 M 15.41 6.51 L 8.59 10.49",
- IconName.ExternalLink => "M 15 3 L 21 3 L 21 9 M 10 14 L 21 3 M 18 13 L 18 19 A 2 2 0 0 1 16 21 L 5 21 A 2 2 0 0 1 3 19 L 3 8 A 2 2 0 0 1 5 6 L 11 6",
- IconName.Link => "M 10 13 A 5 5 0 0 0 17.54 13.54 L 20.54 10.54 A 5 5 0 0 0 13.47 3.47 L 11.75 5.18 M 14 11 A 5 5 0 0 0 6.46 10.46 L 3.46 13.46 A 5 5 0 0 0 10.53 20.53 L 12.24 18.82",
- IconName.RefreshCw => "M 3 12 A 9 9 0 0 1 12 3 A 9.75 9.75 0 0 1 18.74 5.74 L 21 8 M 21 3 L 21 8 L 16 8 M 21 12 A 9 9 0 0 1 12 21 A 9.75 9.75 0 0 1 5.26 18.26 L 3 16 M 8 16 L 3 16 L 3 21",
- IconName.RotateCcw => "M 3 12 A 9 9 0 1 0 12 3 A 9.75 9.75 0 0 0 5.26 5.74 L 3 8 M 3 3 L 3 8 L 8 8",
- IconName.Undo2 => "M 9 14 L 4 9 L 9 4 M 4 9 L 14.5 9 A 5.5 5.5 0 0 1 20 14.5 A 5.5 5.5 0 0 1 14.5 20 L 11 20",
- IconName.Redo2 => "M 15 14 L 20 9 L 15 4 M 20 9 L 9.5 9 A 5.5 5.5 0 0 0 4 14.5 A 5.5 5.5 0 0 0 9.5 20 L 13 20",
- IconName.LogIn => "M 15 3 L 19 3 A 2 2 0 0 1 21 5 L 21 19 A 2 2 0 0 1 19 21 L 15 21 M 10 17 L 15 12 L 10 7 M 15 12 L 3 12",
- IconName.LogOut => "M 9 21 L 5 21 A 2 2 0 0 1 3 19 L 3 5 A 2 2 0 0 1 5 3 L 9 3 M 16 17 L 21 12 L 16 7 M 21 12 L 9 12",
- IconName.Send => "M 14.536 21.686 A 0.5 0.5 0 0 0 15.473 21.662 L 21.973 2.662 A 0.496 0.496 0 0 0 21.338 2.027 L 2.338 8.527 A 0.5 0.5 0 0 0 2.314 9.464 L 10.244 12.644 A 2 2 0 0 1 11.356 13.754 Z M 21.854 2.147 L 10.914 13.086",
- IconName.Paperclip => "M 13.234 20.252 L 21 12.3 M 16 6 L 7.586 14.586 A 2 2 0 0 0 7.586 17.414 A 2 2 0 0 0 10.414 17.414 L 18.828 8.828 A 4 4 0 0 0 18.828 3.172 A 4 4 0 0 0 13.172 3.172 L 4.757 11.757 A 6 6 0 1 0 13.243 20.243",
+ IconName.Copy => "M 10 8 L 20 8 C 21.105 8 22 8.895 22 10 L 22 20 C 22 21.105 21.105 22 20 22 L 10 22 C 8.895 22 8 21.105 8 20 L 8 10 C 8 8.895 8.895 8 10 8 Z M 4 16 C 2.9 16 2 15.1 2 14 L 2 4 C 2 2.9 2.9 2 4 2 L 14 2 C 15.1 2 16 2.9 16 4",
+ IconName.Clipboard => "M 9 2 L 15 2 C 15.552 2 16 2.448 16 3 L 16 5 C 16 5.552 15.552 6 15 6 L 9 6 C 8.448 6 8 5.552 8 5 L 8 3 C 8 2.448 8.448 2 9 2 Z M 16 4 L 18 4 C 19.105 4 20 4.895 20 6 L 20 20 C 20 21.105 19.105 22 18 22 L 6 22 C 4.895 22 4 21.105 4 20 L 4 6 C 4 4.895 4.895 4 6 4 L 8 4",
+ IconName.Save => "M 15.2 3 C 15.728 3.008 16.231 3.223 16.6 3.6 L 20.4 7.4 C 20.777 7.769 20.992 8.272 21 8.8 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 17 21 L 17 14 C 17 13.448 16.552 13 16 13 L 8 13 C 7.448 13 7 13.448 7 14 L 7 21 M 7 3 L 7 7 C 7 7.552 7.448 8 8 8 L 15 8",
+ IconName.Download => "M 21 15 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 15 M 7 10 L 12 15 L 17 10 M 12 15 L 12 3",
+ IconName.Upload => "M 21 15 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 15 M 17 8 L 12 3 L 7 8 M 12 3 L 12 15",
+ IconName.Share => "M 4 12 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 12 M 16 6 L 12 2 L 8 6 M 12 2 L 12 15",
+ IconName.Share2 => "M 21 5 C 21 6.657 19.657 8 18 8 C 16.343 8 15 6.657 15 5 C 15 3.343 16.343 2 18 2 C 19.657 2 21 3.343 21 5 Z M 9 12 C 9 13.657 7.657 15 6 15 C 4.343 15 3 13.657 3 12 C 3 10.343 4.343 9 6 9 C 7.657 9 9 10.343 9 12 Z M 21 19 C 21 20.657 19.657 22 18 22 C 16.343 22 15 20.657 15 19 C 15 17.343 16.343 16 18 16 C 19.657 16 21 17.343 21 19 Z M 8.59 13.51 L 15.42 17.49 M 15.41 6.51 L 8.59 10.49",
+ IconName.ExternalLink => "M 15 3 L 21 3 L 21 9 M 10 14 L 21 3 M 18 13 L 18 19 C 18 20.105 17.105 21 16 21 L 5 21 C 3.895 21 3 20.105 3 19 L 3 8 C 3 6.895 3.895 6 5 6 L 11 6",
+ IconName.Link => "M 10 13 C 10.869 14.162 12.2 14.889 13.647 14.992 C 15.094 15.096 16.514 14.566 17.54 13.54 L 20.54 10.54 C 22.435 8.578 22.408 5.46 20.479 3.531 C 18.55 1.602 15.432 1.575 13.47 3.47 L 11.75 5.18 M 14 11 C 13.131 9.838 11.8 9.111 10.353 9.008 C 8.906 8.904 7.486 9.434 6.46 10.46 L 3.46 13.46 C 1.565 15.422 1.592 18.54 3.521 20.469 C 5.45 22.398 8.568 22.425 10.53 20.53 L 12.24 18.82",
+ IconName.RefreshCw => "M 3 12 C 3 7.029 7.029 3 12 3 C 14.516 3.009 16.931 3.991 18.74 5.74 L 21 8 M 21 3 L 21 8 L 16 8 M 21 12 C 21 16.971 16.971 21 12 21 C 9.484 20.991 7.069 20.009 5.26 18.26 L 3 16 M 8 16 L 3 16 L 3 21",
+ IconName.RotateCcw => "M 3 12 C 3 16.971 7.029 21 12 21 C 16.971 21 21 16.971 21 12 C 21 7.029 16.971 3 12 3 C 9.484 3.009 7.069 3.991 5.26 5.74 L 3 8 M 3 3 L 3 8 L 8 8",
+ IconName.Undo2 => "M 9 14 L 4 9 L 9 4 M 4 9 L 14.5 9 C 17.538 9 20 11.462 20 14.5 C 20 17.538 17.538 20 14.5 20 L 11 20",
+ IconName.Redo2 => "M 15 14 L 20 9 L 15 4 M 20 9 L 9.5 9 C 6.462 9 4 11.462 4 14.5 C 4 17.538 6.462 20 9.5 20 L 13 20",
+ IconName.LogIn => "M 15 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 15 21 M 10 17 L 15 12 L 10 7 M 15 12 L 3 12",
+ IconName.LogOut => "M 9 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 L 9 3 M 16 17 L 21 12 L 16 7 M 21 12 L 9 12",
+ IconName.Send => "M 14.536 21.686 C 14.614 21.88 14.804 22.005 15.013 22 C 15.222 21.994 15.405 21.86 15.473 21.662 L 21.973 2.662 C 22.038 2.481 21.993 2.279 21.857 2.143 C 21.721 2.007 21.519 1.962 21.338 2.027 L 2.338 8.527 C 2.14 8.595 2.006 8.778 2 8.987 C 1.995 9.196 2.12 9.386 2.314 9.464 L 10.244 12.644 C 10.751 12.847 11.152 13.248 11.356 13.754 Z M 21.854 2.147 L 10.914 13.086",
+ IconName.Paperclip => "M 13.234 20.252 L 21 12.3 M 16 6 L 7.586 14.586 C 6.805 15.367 6.805 16.633 7.586 17.414 C 8.367 18.195 9.633 18.195 10.414 17.414 L 18.828 8.828 C 20.39 7.266 20.39 4.734 18.828 3.172 C 17.266 1.61 14.734 1.61 13.172 3.172 L 4.757 11.757 C 2.414 14.1 2.414 17.9 4.757 20.243 C 7.1 22.586 10.9 22.586 13.243 20.243",
IconName.ChevronDown => "M 6 9 L 12 15 L 18 9",
IconName.ChevronUp => "M 18 15 L 12 9 L 6 15",
IconName.ChevronLeft => "M 15 18 L 9 12 L 15 6",
@@ -168,75 +168,75 @@ public static class IconPaths
IconName.ArrowDown => "M 12 5 L 12 19 M 19 12 L 12 19 L 5 12",
IconName.ArrowUpRight => "M 7 7 L 17 7 L 17 17 M 7 17 L 17 7",
IconName.Menu => "M 4 12 L 20 12 M 4 6 L 20 6 M 4 18 L 20 18",
- IconName.Ellipsis => "M 13 12 A 1 1 0 1 1 11 12 A 1 1 0 1 1 13 12 Z M 20 12 A 1 1 0 1 1 18 12 A 1 1 0 1 1 20 12 Z M 6 12 A 1 1 0 1 1 4 12 A 1 1 0 1 1 6 12 Z",
- IconName.EllipsisVertical => "M 13 12 A 1 1 0 1 1 11 12 A 1 1 0 1 1 13 12 Z M 13 5 A 1 1 0 1 1 11 5 A 1 1 0 1 1 13 5 Z M 13 19 A 1 1 0 1 1 11 19 A 1 1 0 1 1 13 19 Z",
- IconName.GripVertical => "M 10 12 A 1 1 0 1 1 8 12 A 1 1 0 1 1 10 12 Z M 10 5 A 1 1 0 1 1 8 5 A 1 1 0 1 1 10 5 Z M 10 19 A 1 1 0 1 1 8 19 A 1 1 0 1 1 10 19 Z M 16 12 A 1 1 0 1 1 14 12 A 1 1 0 1 1 16 12 Z M 16 5 A 1 1 0 1 1 14 5 A 1 1 0 1 1 16 5 Z M 16 19 A 1 1 0 1 1 14 19 A 1 1 0 1 1 16 19 Z",
- IconName.House => "M 15 21 L 15 13 A 1 1 0 0 0 14 12 L 10 12 A 1 1 0 0 0 9 13 L 9 21 M 3 10 A 2 2 0 0 1 3.709 8.472 L 10.709 2.473 A 2 2 0 0 1 13.291 2.473 L 20.291 8.472 A 2 2 0 0 1 21 10 L 21 19 A 2 2 0 0 1 19 21 L 5 21 A 2 2 0 0 1 3 19 Z",
- IconName.LayoutDashboard => "M 4 3 L 9 3 A 1 1 0 0 1 10 4 L 10 11 A 1 1 0 0 1 9 12 L 4 12 A 1 1 0 0 1 3 11 L 3 4 A 1 1 0 0 1 4 3 Z M 15 3 L 20 3 A 1 1 0 0 1 21 4 L 21 7 A 1 1 0 0 1 20 8 L 15 8 A 1 1 0 0 1 14 7 L 14 4 A 1 1 0 0 1 15 3 Z M 15 12 L 20 12 A 1 1 0 0 1 21 13 L 21 20 A 1 1 0 0 1 20 21 L 15 21 A 1 1 0 0 1 14 20 L 14 13 A 1 1 0 0 1 15 12 Z M 4 16 L 9 16 A 1 1 0 0 1 10 17 L 10 20 A 1 1 0 0 1 9 21 L 4 21 A 1 1 0 0 1 3 20 L 3 17 A 1 1 0 0 1 4 16 Z",
- IconName.PanelLeft => "M 5 3 L 19 3 A 2 2 0 0 1 21 5 L 21 19 A 2 2 0 0 1 19 21 L 5 21 A 2 2 0 0 1 3 19 L 3 5 A 2 2 0 0 1 5 3 Z M 9 3 L 9 21",
- IconName.Info => "M 22 12 A 10 10 0 1 1 2 12 A 10 10 0 1 1 22 12 Z M 12 16 L 12 12 M 12 8 L 12.01 8",
- IconName.CircleAlert => "M 22 12 A 10 10 0 1 1 2 12 A 10 10 0 1 1 22 12 Z M 12 8 L 12 12 M 12 16 L 12.01 16",
- IconName.CircleCheck => "M 22 12 A 10 10 0 1 1 2 12 A 10 10 0 1 1 22 12 Z M 9 12 L 11 14 L 15 10",
- IconName.CircleX => "M 22 12 A 10 10 0 1 1 2 12 A 10 10 0 1 1 22 12 Z M 15 9 L 9 15 M 9 9 L 15 15",
- IconName.CircleHelp => "M 22 12 A 10 10 0 1 1 2 12 A 10 10 0 1 1 22 12 Z M 9.09 9 A 3 3 0 0 1 14.92 10 C 14.92 12 11.92 13 11.92 13 M 12 17 L 12.01 17",
- IconName.TriangleAlert => "M 21.73 18 L 13.73 4 A 2 2 0 0 0 10.25 4 L 2.25 18 A 2 2 0 0 0 4 21 L 20 21 A 2 2 0 0 0 21.73 18 M 12 9 L 12 13 M 12 17 L 12.01 17",
- IconName.Ban => "M 22 12 A 10 10 0 1 1 2 12 A 10 10 0 1 1 22 12 Z M 4.9 4.9 L 19.1 19.1",
+ IconName.Ellipsis => "M 13 12 C 13 12.552 12.552 13 12 13 C 11.448 13 11 12.552 11 12 C 11 11.448 11.448 11 12 11 C 12.552 11 13 11.448 13 12 Z M 20 12 C 20 12.552 19.552 13 19 13 C 18.448 13 18 12.552 18 12 C 18 11.448 18.448 11 19 11 C 19.552 11 20 11.448 20 12 Z M 6 12 C 6 12.552 5.552 13 5 13 C 4.448 13 4 12.552 4 12 C 4 11.448 4.448 11 5 11 C 5.552 11 6 11.448 6 12 Z",
+ IconName.EllipsisVertical => "M 13 12 C 13 12.552 12.552 13 12 13 C 11.448 13 11 12.552 11 12 C 11 11.448 11.448 11 12 11 C 12.552 11 13 11.448 13 12 Z M 13 5 C 13 5.552 12.552 6 12 6 C 11.448 6 11 5.552 11 5 C 11 4.448 11.448 4 12 4 C 12.552 4 13 4.448 13 5 Z M 13 19 C 13 19.552 12.552 20 12 20 C 11.448 20 11 19.552 11 19 C 11 18.448 11.448 18 12 18 C 12.552 18 13 18.448 13 19 Z",
+ IconName.GripVertical => "M 10 12 C 10 12.552 9.552 13 9 13 C 8.448 13 8 12.552 8 12 C 8 11.448 8.448 11 9 11 C 9.552 11 10 11.448 10 12 Z M 10 5 C 10 5.552 9.552 6 9 6 C 8.448 6 8 5.552 8 5 C 8 4.448 8.448 4 9 4 C 9.552 4 10 4.448 10 5 Z M 10 19 C 10 19.552 9.552 20 9 20 C 8.448 20 8 19.552 8 19 C 8 18.448 8.448 18 9 18 C 9.552 18 10 18.448 10 19 Z M 16 12 C 16 12.552 15.552 13 15 13 C 14.448 13 14 12.552 14 12 C 14 11.448 14.448 11 15 11 C 15.552 11 16 11.448 16 12 Z M 16 5 C 16 5.552 15.552 6 15 6 C 14.448 6 14 5.552 14 5 C 14 4.448 14.448 4 15 4 C 15.552 4 16 4.448 16 5 Z M 16 19 C 16 19.552 15.552 20 15 20 C 14.448 20 14 19.552 14 19 C 14 18.448 14.448 18 15 18 C 15.552 18 16 18.448 16 19 Z",
+ IconName.House => "M 15 21 L 15 13 C 15 12.448 14.552 12 14 12 L 10 12 C 9.448 12 9 12.448 9 13 L 9 21 M 3 10 C 3 9.411 3.259 8.852 3.709 8.472 L 10.709 2.473 C 11.454 1.843 12.546 1.843 13.291 2.473 L 20.291 8.472 C 20.741 8.852 21 9.411 21 10 L 21 19 C 21 20.105 20.105 21 19 21 L 5 21 C 3.895 21 3 20.105 3 19 Z",
+ IconName.LayoutDashboard => "M 4 3 L 9 3 C 9.552 3 10 3.448 10 4 L 10 11 C 10 11.552 9.552 12 9 12 L 4 12 C 3.448 12 3 11.552 3 11 L 3 4 C 3 3.448 3.448 3 4 3 Z M 15 3 L 20 3 C 20.552 3 21 3.448 21 4 L 21 7 C 21 7.552 20.552 8 20 8 L 15 8 C 14.448 8 14 7.552 14 7 L 14 4 C 14 3.448 14.448 3 15 3 Z M 15 12 L 20 12 C 20.552 12 21 12.448 21 13 L 21 20 C 21 20.552 20.552 21 20 21 L 15 21 C 14.448 21 14 20.552 14 20 L 14 13 C 14 12.448 14.448 12 15 12 Z M 4 16 L 9 16 C 9.552 16 10 16.448 10 17 L 10 20 C 10 20.552 9.552 21 9 21 L 4 21 C 3.448 21 3 20.552 3 20 L 3 17 C 3 16.448 3.448 16 4 16 Z",
+ IconName.PanelLeft => "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 9 3 L 9 21",
+ IconName.Info => "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 16 L 12 12 M 12 8 L 12.01 8",
+ IconName.CircleAlert => "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 8 L 12 12 M 12 16 L 12.01 16",
+ IconName.CircleCheck => "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 9 12 L 11 14 L 15 10",
+ IconName.CircleX => "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 15 9 L 9 15 M 9 9 L 15 15",
+ IconName.CircleHelp => "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 9.09 9 C 9.575 7.62 10.985 6.791 12.427 7.039 C 13.869 7.286 14.922 8.537 14.92 10 C 14.92 12 11.92 13 11.92 13 M 12 17 L 12.01 17",
+ IconName.TriangleAlert => "M 21.73 18 L 13.73 4 C 13.375 3.373 12.71 2.986 11.99 2.986 C 11.27 2.986 10.605 3.373 10.25 4 L 2.25 18 C 1.891 18.622 1.893 19.388 2.254 20.008 C 2.616 20.628 3.282 21.006 4 21 L 20 21 C 20.714 20.999 21.374 20.618 21.731 19.999 C 22.087 19.38 22.087 18.618 21.73 18 M 12 9 L 12 13 M 12 17 L 12.01 17",
+ IconName.Ban => "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 4.9 4.9 L 19.1 19.1",
IconName.Loader => "M 12 2 L 12 6 M 16.2 7.8 L 19.1 4.9 M 18 12 L 22 12 M 16.2 16.2 L 19.1 19.1 M 12 18 L 12 22 M 4.9 19.1 L 7.8 16.2 M 2 12 L 6 12 M 4.9 4.9 L 7.8 7.8",
- IconName.LoaderCircle => "M 21 12 A 9 9 0 1 1 14.781 3.44",
- IconName.Bell => "M 10.268 21 A 2 2 0 0 0 13.732 21 M 3.262 15.326 A 1 1 0 0 0 4 17 L 20 17 A 1 1 0 0 0 20.74 15.327 C 19.41 13.956 18 12.499 18 8 A 6 6 0 0 0 6 8 C 6 12.499 4.589 13.956 3.262 15.326",
- IconName.Circle => "M 22 12 A 10 10 0 1 1 2 12 A 10 10 0 1 1 22 12 Z",
- IconName.CirclePlus => "M 22 12 A 10 10 0 1 1 2 12 A 10 10 0 1 1 22 12 Z M 8 12 L 16 12 M 12 8 L 12 16",
- IconName.Dot => "M 13.1 12.1 A 1 1 0 1 1 11.1 12.1 A 1 1 0 1 1 13.1 12.1 Z",
- IconName.User => "M 19 21 L 19 19 A 4 4 0 0 0 15 15 L 9 15 A 4 4 0 0 0 5 19 L 5 21 M 16 7 A 4 4 0 1 1 8 7 A 4 4 0 1 1 16 7 Z",
- IconName.Users => "M 16 21 L 16 19 A 4 4 0 0 0 12 15 L 6 15 A 4 4 0 0 0 2 19 L 2 21 M 13 7 A 4 4 0 1 1 5 7 A 4 4 0 1 1 13 7 Z M 22 21 L 22 19 A 4 4 0 0 0 19 15.13 M 16 3.13 A 4 4 0 0 1 16 10.88",
- IconName.UserPlus => "M 16 21 L 16 19 A 4 4 0 0 0 12 15 L 6 15 A 4 4 0 0 0 2 19 L 2 21 M 13 7 A 4 4 0 1 1 5 7 A 4 4 0 1 1 13 7 Z M 19 8 L 19 14 M 22 11 L 16 11",
- IconName.Lock => "M 5 11 L 19 11 A 2 2 0 0 1 21 13 L 21 20 A 2 2 0 0 1 19 22 L 5 22 A 2 2 0 0 1 3 20 L 3 13 A 2 2 0 0 1 5 11 Z M 7 11 L 7 7 A 5 5 0 0 1 17 7 L 17 11",
- IconName.LockOpen => "M 5 11 L 19 11 A 2 2 0 0 1 21 13 L 21 20 A 2 2 0 0 1 19 22 L 5 22 A 2 2 0 0 1 3 20 L 3 13 A 2 2 0 0 1 5 11 Z M 7 11 L 7 7 A 5 5 0 0 1 16.9 6",
- IconName.Key => "M 15.5 7.5 L 17.8 9.8 A 1 1 0 0 0 19.2 9.8 L 21.3 7.7 A 1 1 0 0 0 21.3 6.3 L 19 4 M 21 2 L 11.4 11.6 M 13 15.5 A 5.5 5.5 0 1 1 2 15.5 A 5.5 5.5 0 1 1 13 15.5 Z",
- IconName.Shield => "M 20 13 C 20 18 16.5 20.5 12.34 21.95 A 1 1 0 0 1 11.67 21.94 C 7.5 20.5 4 18 4 13 L 4 6 A 1 1 0 0 1 5 5 C 7 5 9.5 3.8 11.24 2.28 A 1.17 1.17 0 0 1 12.76 2.28 C 14.51 3.81 17 5 19 5 A 1 1 0 0 1 20 6 Z",
- IconName.Eye => "M 2.062 12.348 A 1 1 0 0 1 2.062 11.652 A 10.75 10.75 0 0 1 21.938 11.652 A 1 1 0 0 1 21.938 12.348 A 10.75 10.75 0 0 1 2.062 12.348 M 15 12 A 3 3 0 1 1 9 12 A 3 3 0 1 1 15 12 Z",
- IconName.EyeOff => "M 10.733 5.076 A 10.744 10.744 0 0 1 21.938 11.651 A 1 1 0 0 1 21.938 12.347 A 10.747 10.747 0 0 1 20.494 14.837 M 14.084 14.158 A 3 3 0 0 1 9.842 9.916 M 17.479 17.499 A 10.75 10.75 0 0 1 2.062 12.348 A 1 1 0 0 1 2.062 11.652 A 10.75 10.75 0 0 1 6.508 6.509 M 2 2 L 22 22",
- IconName.Mail => "M 4 4 L 20 4 A 2 2 0 0 1 22 6 L 22 18 A 2 2 0 0 1 20 20 L 4 20 A 2 2 0 0 1 2 18 L 2 6 A 2 2 0 0 1 4 4 Z M 22 7 L 13.03 12.7 A 1.94 1.94 0 0 1 10.97 12.7 L 2 7",
- IconName.Inbox => "M 22 12 L 16 12 L 14 15 L 10 15 L 8 12 L 2 12 M 5.45 5.11 L 2 12 L 2 18 A 2 2 0 0 0 4 20 L 20 20 A 2 2 0 0 0 22 18 L 22 12 L 18.55 5.11 A 2 2 0 0 0 16.76 4 L 7.24 4 A 2 2 0 0 0 5.45 5.11 Z",
- IconName.Archive => "M 3 3 L 21 3 A 1 1 0 0 1 22 4 L 22 7 A 1 1 0 0 1 21 8 L 3 8 A 1 1 0 0 1 2 7 L 2 4 A 1 1 0 0 1 3 3 Z M 4 8 L 4 19 A 2 2 0 0 0 6 21 L 18 21 A 2 2 0 0 0 20 19 L 20 8 M 10 12 L 14 12",
- IconName.MessageCircle => "M 7.9 20 A 9 9 0 1 0 4 16.1 L 2 22 Z",
- IconName.Phone => "M 22 16.92 L 22 19.92 A 2 2 0 0 1 19.82 21.92 A 19.79 19.79 0 0 1 11.19 18.85 A 19.5 19.5 0 0 1 5.19 12.85 A 19.79 19.79 0 0 1 2.12 4.18 A 2 2 0 0 1 4.11 2 L 7.11 2 A 2 2 0 0 1 9.11 3.72 A 12.84 12.84 0 0 0 9.81 6.53 A 2 2 0 0 1 9.36 8.64 L 8.09 9.91 A 16 16 0 0 0 14.09 15.91 L 15.36 14.64 A 2 2 0 0 1 17.47 14.19 A 12.84 12.84 0 0 0 20.28 14.89 A 2 2 0 0 1 22 16.92 Z",
- IconName.Calendar => "M 8 2 L 8 6 M 16 2 L 16 6 M 5 4 L 19 4 A 2 2 0 0 1 21 6 L 21 20 A 2 2 0 0 1 19 22 L 5 22 A 2 2 0 0 1 3 20 L 3 6 A 2 2 0 0 1 5 4 Z M 3 10 L 21 10",
- IconName.Clock => "M 22 12 A 10 10 0 1 1 2 12 A 10 10 0 1 1 22 12 Z M 12 6 L 12 12 L 16 14",
- IconName.Globe => "M 22 12 A 10 10 0 1 1 2 12 A 10 10 0 1 1 22 12 Z M 12 2 A 14.5 14.5 0 0 0 12 22 A 14.5 14.5 0 0 0 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 A 1 1 0 0 1 11.399 21.799 C 9.539 20.193 4 14.993 4 10 A 8 8 0 0 1 20 10 M 15 10 A 3 3 0 1 1 9 10 A 3 3 0 1 1 15 10 Z",
- IconName.Image => "M 5 3 L 19 3 A 2 2 0 0 1 21 5 L 21 19 A 2 2 0 0 1 19 21 L 5 21 A 2 2 0 0 1 3 19 L 3 5 A 2 2 0 0 1 5 3 Z M 11 9 A 2 2 0 1 1 7 9 A 2 2 0 1 1 11 9 Z M 21 15 L 17.914 11.914 A 2 2 0 0 0 15.086 11.914 L 6 21",
- IconName.File => "M 15 2 L 6 2 A 2 2 0 0 0 4 4 L 4 20 A 2 2 0 0 0 6 22 L 18 22 A 2 2 0 0 0 20 20 L 20 7 Z M 14 2 L 14 6 A 2 2 0 0 0 16 8 L 20 8",
- IconName.FileText => "M 15 2 L 6 2 A 2 2 0 0 0 4 4 L 4 20 A 2 2 0 0 0 6 22 L 18 22 A 2 2 0 0 0 20 20 L 20 7 Z M 14 2 L 14 6 A 2 2 0 0 0 16 8 L 20 8 M 10 9 L 8 9 M 16 13 L 8 13 M 16 17 L 8 17",
- IconName.Folder => "M 20 20 A 2 2 0 0 0 22 18 L 22 8 A 2 2 0 0 0 20 6 L 12.1 6 A 2 2 0 0 1 10.41 5.1 L 9.6 3.9 A 2 2 0 0 0 7.93 3 L 4 3 A 2 2 0 0 0 2 5 L 2 18 A 2 2 0 0 0 4 20 Z",
- IconName.Tag => "M 12.586 2.586 A 2 2 0 0 0 11.172 2 L 4 2 A 2 2 0 0 0 2 4 L 2 11.172 A 2 2 0 0 0 2.586 12.586 L 11.29 21.29 A 2.426 2.426 0 0 0 14.71 21.29 L 21.29 14.71 A 2.426 2.426 0 0 0 21.29 11.29 Z M 8 7.5 A 0.5 0.5 0 1 1 7 7.5 A 0.5 0.5 0 1 1 8 7.5 Z",
- IconName.Bookmark => "M 19 21 L 12 17 L 5 21 L 5 5 A 2 2 0 0 1 7 3 L 17 3 A 2 2 0 0 1 19 5 L 19 21 Z",
- IconName.Heart => "M 19 14 C 20.49 12.54 22 10.79 22 8.5 A 5.5 5.5 0 0 0 16.5 3 C 14.74 3 13.5 3.5 12 5 C 10.5 3.5 9.26 3 7.5 3 A 5.5 5.5 0 0 0 2 8.5 C 2 10.8 3.5 12.55 5 14 L 12 21 Z",
- IconName.Star => "M 11.525 2.295 A 0.53 0.53 0 0 1 12.475 2.295 L 14.785 6.974 A 2.123 2.123 0 0 0 16.38 8.134 L 21.546 8.89 A 0.53 0.53 0 0 1 21.84 9.794 L 18.104 13.432 A 2.123 2.123 0 0 0 17.493 15.31 L 18.375 20.45 A 0.53 0.53 0 0 1 17.604 21.01 L 12.986 18.582 A 2.122 2.122 0 0 0 11.013 18.582 L 6.396 21.01 A 0.53 0.53 0 0 1 5.626 20.45 L 6.507 15.311 A 2.122 2.122 0 0 0 5.896 13.432 L 2.16 9.795 A 0.53 0.53 0 0 1 2.454 8.889 L 7.619 8.134 A 2.122 2.122 0 0 0 9.216 6.974 Z",
- IconName.ThumbsUp => "M 7 10 L 7 22 M 15 5.88 L 14 10 L 19.83 10 A 2 2 0 0 1 21.75 12.56 L 19.42 20.56 A 2 2 0 0 1 17.5 22 L 4 22 A 2 2 0 0 1 2 20 L 2 12 A 2 2 0 0 1 4 10 L 6.76 10 A 2 2 0 0 0 8.55 8.89 L 12 2 A 3.13 3.13 0 0 1 15 5.88 Z",
- IconName.ShoppingCart => "M 9 21 A 1 1 0 1 1 7 21 A 1 1 0 1 1 9 21 Z M 20 21 A 1 1 0 1 1 18 21 A 1 1 0 1 1 20 21 Z M 2.05 2.05 L 4.05 2.05 L 6.71 14.47 A 2 2 0 0 0 8.71 16.05 L 18.49 16.05 A 2 2 0 0 0 20.44 14.48 L 22.09 7.05 L 5.12 7.05",
- IconName.CreditCard => "M 4 5 L 20 5 A 2 2 0 0 1 22 7 L 22 17 A 2 2 0 0 1 20 19 L 4 19 A 2 2 0 0 1 2 17 L 2 7 A 2 2 0 0 1 4 5 Z M 2 10 L 22 10",
- IconName.DollarSign => "M 12 2 L 12 22 M 17 5 L 9.5 5 A 3.5 3.5 0 0 0 9.5 12 L 14.5 12 A 3.5 3.5 0 0 1 14.5 19 L 6 19",
- IconName.Activity => "M 22 12 L 19.52 12 A 2 2 0 0 0 17.59 13.46 L 15.24 21.82 A 0.25 0.25 0 0 1 14.76 21.82 L 9.24 2.18 A 0.25 0.25 0 0 0 8.76 2.18 L 6.41 10.54 A 2 2 0 0 1 4.49 12 L 2 12",
+ IconName.LoaderCircle => "M 21 12 C 21 15.899 18.489 19.354 14.781 20.559 C 11.073 21.764 7.01 20.444 4.719 17.29 C 2.427 14.135 2.427 9.864 4.719 6.709 C 7.011 3.555 11.073 2.235 14.781 3.44",
+ IconName.Bell => "M 10.268 21 C 10.625 21.619 11.286 22 12 22 C 12.714 22 13.375 21.619 13.732 21 M 3.262 15.326 C 2.995 15.619 2.926 16.041 3.086 16.403 C 3.245 16.766 3.604 17 4 17 L 20 17 C 20.396 17 20.755 16.767 20.915 16.405 C 21.075 16.042 21.006 15.62 20.74 15.327 C 19.41 13.956 18 12.499 18 8 C 18 4.686 15.314 2 12 2 C 8.686 2 6 4.686 6 8 C 6 12.499 4.589 13.956 3.262 15.326",
+ IconName.Circle => "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",
+ IconName.CirclePlus => "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 8 12 L 16 12 M 12 8 L 12 16",
+ IconName.Dot => "M 13.1 12.1 C 13.1 12.652 12.652 13.1 12.1 13.1 C 11.548 13.1 11.1 12.652 11.1 12.1 C 11.1 11.548 11.548 11.1 12.1 11.1 C 12.652 11.1 13.1 11.548 13.1 12.1 Z",
+ IconName.User => "M 19 21 L 19 19 C 19 16.791 17.209 15 15 15 L 9 15 C 6.791 15 5 16.791 5 19 L 5 21 M 16 7 C 16 9.209 14.209 11 12 11 C 9.791 11 8 9.209 8 7 C 8 4.791 9.791 3 12 3 C 14.209 3 16 4.791 16 7 Z",
+ IconName.Users => "M 16 21 L 16 19 C 16 16.791 14.209 15 12 15 L 6 15 C 3.791 15 2 16.791 2 19 L 2 21 M 13 7 C 13 9.209 11.209 11 9 11 C 6.791 11 5 9.209 5 7 C 5 4.791 6.791 3 9 3 C 11.209 3 13 4.791 13 7 Z M 22 21 L 22 19 C 21.999 17.177 20.765 15.586 19 15.13 M 16 3.13 C 17.77 3.583 19.008 5.178 19.008 7.005 C 19.008 8.832 17.77 10.427 16 10.88",
+ IconName.UserPlus => "M 16 21 L 16 19 C 16 16.791 14.209 15 12 15 L 6 15 C 3.791 15 2 16.791 2 19 L 2 21 M 13 7 C 13 9.209 11.209 11 9 11 C 6.791 11 5 9.209 5 7 C 5 4.791 6.791 3 9 3 C 11.209 3 13 4.791 13 7 Z M 19 8 L 19 14 M 22 11 L 16 11",
+ 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.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",
+ IconName.Mail => "M 4 4 L 20 4 C 21.105 4 22 4.895 22 6 L 22 18 C 22 19.105 21.105 20 20 20 L 4 20 C 2.895 20 2 19.105 2 18 L 2 6 C 2 4.895 2.895 4 4 4 Z M 22 7 L 13.03 12.7 C 12.4 13.095 11.6 13.095 10.97 12.7 L 2 7",
+ IconName.Inbox => "M 22 12 L 16 12 L 14 15 L 10 15 L 8 12 L 2 12 M 5.45 5.11 L 2 12 L 2 18 C 2 19.105 2.895 20 4 20 L 20 20 C 21.105 20 22 19.105 22 18 L 22 12 L 18.55 5.11 C 18.212 4.43 17.519 4 16.76 4 L 7.24 4 C 6.481 4 5.788 4.43 5.45 5.11 Z",
+ IconName.Archive => "M 3 3 L 21 3 C 21.552 3 22 3.448 22 4 L 22 7 C 22 7.552 21.552 8 21 8 L 3 8 C 2.448 8 2 7.552 2 7 L 2 4 C 2 3.448 2.448 3 3 3 Z M 4 8 L 4 19 C 4 20.105 4.895 21 6 21 L 18 21 C 19.105 21 20 20.105 20 19 L 20 8 M 10 12 L 14 12",
+ 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.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.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",
+ IconName.Heart => "M 19 14 C 20.49 12.54 22 10.79 22 8.5 C 22 5.462 19.538 3 16.5 3 C 14.74 3 13.5 3.5 12 5 C 10.5 3.5 9.26 3 7.5 3 C 4.462 3 2 5.462 2 8.5 C 2 10.8 3.5 12.55 5 14 L 12 21 Z",
+ IconName.Star => "M 11.525 2.295 C 11.614 2.114 11.798 2 12 2 C 12.202 2 12.386 2.114 12.475 2.295 L 14.785 6.974 C 15.094 7.599 15.69 8.033 16.38 8.134 L 21.546 8.89 C 21.746 8.919 21.912 9.059 21.974 9.251 C 22.036 9.442 21.984 9.653 21.84 9.794 L 18.104 13.432 C 17.604 13.919 17.375 14.622 17.493 15.31 L 18.375 20.45 C 18.41 20.65 18.329 20.852 18.165 20.971 C 18.001 21.09 17.783 21.105 17.604 21.01 L 12.986 18.582 C 12.368 18.258 11.631 18.258 11.013 18.582 L 6.396 21.01 C 6.217 21.105 6 21.089 5.836 20.97 C 5.673 20.851 5.591 20.649 5.626 20.45 L 6.507 15.311 C 6.625 14.622 6.397 13.92 5.896 13.432 L 2.16 9.795 C 2.014 9.654 1.962 9.443 2.024 9.25 C 2.087 9.058 2.253 8.917 2.454 8.889 L 7.619 8.134 C 8.31 8.034 8.907 7.6 9.216 6.974 Z",
+ IconName.ThumbsUp => "M 7 10 L 7 22 M 15 5.88 L 14 10 L 19.83 10 C 20.46 10 21.052 10.296 21.43 10.8 C 21.808 11.304 21.926 11.956 21.75 12.56 L 19.42 20.56 C 19.171 21.413 18.389 22 17.5 22 L 4 22 C 2.895 22 2 21.105 2 20 L 2 12 C 2 10.895 2.895 10 4 10 L 6.76 10 C 7.519 10 8.212 9.57 8.55 8.89 L 12 2 C 12.955 2.012 13.853 2.459 14.437 3.215 C 15.022 3.971 15.229 4.952 15 5.88 Z",
+ IconName.ShoppingCart => "M 9 21 C 9 21.552 8.552 22 8 22 C 7.448 22 7 21.552 7 21 C 7 20.448 7.448 20 8 20 C 8.552 20 9 20.448 9 21 Z M 20 21 C 20 21.552 19.552 22 19 22 C 18.448 22 18 21.552 18 21 C 18 20.448 18.448 20 19 20 C 19.552 20 20 20.448 20 21 Z M 2.05 2.05 L 4.05 2.05 L 6.71 14.47 C 6.911 15.408 7.751 16.071 8.71 16.05 L 18.49 16.05 C 19.428 16.048 20.238 15.396 20.44 14.48 L 22.09 7.05 L 5.12 7.05",
+ IconName.CreditCard => "M 4 5 L 20 5 C 21.105 5 22 5.895 22 7 L 22 17 C 22 18.105 21.105 19 20 19 L 4 19 C 2.895 19 2 18.105 2 17 L 2 7 C 2 5.895 2.895 5 4 5 Z M 2 10 L 22 10",
+ IconName.DollarSign => "M 12 2 L 12 22 M 17 5 L 9.5 5 C 7.567 5 6 6.567 6 8.5 C 6 10.433 7.567 12 9.5 12 L 14.5 12 C 16.433 12 18 13.567 18 15.5 C 18 17.433 16.433 19 14.5 19 L 6 19",
+ IconName.Activity => "M 22 12 L 19.52 12 C 18.622 11.998 17.833 12.595 17.59 13.46 L 15.24 21.82 C 15.209 21.927 15.111 22 15 22 C 14.889 22 14.791 21.927 14.76 21.82 L 9.24 2.18 C 9.209 2.073 9.111 2 9 2 C 8.889 2 8.791 2.073 8.76 2.18 L 6.41 10.54 C 6.168 11.401 5.384 11.997 4.49 12 L 2 12",
IconName.TrendingUp => "M 22 7 L 13.5 15.5 L 8.5 10.5 L 2 17 M 16 7 L 22 7 L 22 13",
- IconName.ChartColumn => "M 3 3 L 3 19 A 2 2 0 0 0 5 21 L 21 21 M 18 17 L 18 9 M 13 17 L 13 5 M 8 17 L 8 14",
- IconName.Zap => "M 4 14 A 1 1 0 0 1 3.22 12.37 L 13.12 2.17 A 0.5 0.5 0 0 1 13.98 2.63 L 12.06 8.65 A 1 1 0 0 0 13 10 L 20 10 A 1 1 0 0 1 20.78 11.63 L 10.88 21.83 A 0.5 0.5 0 0 1 10.02 21.37 L 11.94 15.35 A 1 1 0 0 0 11 14 Z",
- IconName.Sparkles => "M 9.937 15.5 A 2 2 0 0 0 8.5 14.063 L 2.365 12.481 A 0.5 0.5 0 0 1 2.365 11.519 L 8.5 9.936 A 2 2 0 0 0 9.937 8.5 L 11.519 2.365 A 0.5 0.5 0 0 1 12.482 2.365 L 14.063 8.5 A 2 2 0 0 0 15.5 9.937 L 21.635 11.518 A 0.5 0.5 0 0 1 21.635 12.482 L 15.5 14.063 A 2 2 0 0 0 14.063 15.5 L 12.481 21.635 A 0.5 0.5 0 0 1 11.518 21.635 Z M 20 3 L 20 7 M 22 5 L 18 5 M 4 17 L 4 19 M 5 18 L 3 18",
- IconName.Command => "M 15 6 L 15 18 A 3 3 0 1 0 18 15 L 6 15 A 3 3 0 1 0 9 18 L 9 6 A 3 3 0 1 0 6 9 L 18 9 A 3 3 0 1 0 15 6",
+ IconName.ChartColumn => "M 3 3 L 3 19 C 3 20.105 3.895 21 5 21 L 21 21 M 18 17 L 18 9 M 13 17 L 13 5 M 8 17 L 8 14",
+ IconName.Zap => "M 4 14 C 3.614 14.001 3.261 13.78 3.095 13.432 C 2.928 13.083 2.977 12.67 3.22 12.37 L 13.12 2.17 C 13.273 1.993 13.528 1.946 13.734 2.057 C 13.94 2.167 14.042 2.405 13.98 2.63 L 12.06 8.65 C 11.945 8.958 11.988 9.302 12.176 9.571 C 12.364 9.841 12.672 10.001 13 10 L 20 10 C 20.386 9.999 20.739 10.22 20.905 10.568 C 21.072 10.917 21.023 11.33 20.78 11.63 L 10.88 21.83 C 10.727 22.007 10.472 22.054 10.266 21.943 C 10.06 21.833 9.958 21.595 10.02 21.37 L 11.94 15.35 C 12.055 15.042 12.012 14.698 11.824 14.429 C 11.636 14.159 11.328 13.999 11 14 Z",
+ IconName.Sparkles => "M 9.937 15.5 C 9.755 14.795 9.205 14.245 8.5 14.063 L 2.365 12.481 C 2.15 12.42 2.002 12.224 2.002 12 C 2.002 11.776 2.15 11.58 2.365 11.519 L 8.5 9.936 C 9.205 9.754 9.755 9.204 9.937 8.5 L 11.519 2.365 C 11.579 2.149 11.776 2 12 2 C 12.225 2 12.422 2.149 12.482 2.365 L 14.063 8.5 C 14.245 9.205 14.795 9.755 15.5 9.937 L 21.635 11.518 C 21.852 11.578 22.002 11.775 22.002 12 C 22.002 12.225 21.852 12.422 21.635 12.482 L 15.5 14.063 C 14.795 14.245 14.245 14.795 14.063 15.5 L 12.481 21.635 C 12.421 21.851 12.224 22 12 22 C 11.775 22 11.578 21.851 11.518 21.635 Z M 20 3 L 20 7 M 22 5 L 18 5 M 4 17 L 4 19 M 5 18 L 3 18",
+ IconName.Command => "M 15 6 L 15 18 C 15 19.657 16.343 21 18 21 C 19.657 21 21 19.657 21 18 C 21 16.343 19.657 15 18 15 L 6 15 C 4.343 15 3 16.343 3 18 C 3 19.657 4.343 21 6 21 C 7.657 21 9 19.657 9 18 L 9 6 C 9 4.343 7.657 3 6 3 C 4.343 3 3 4.343 3 6 C 3 7.657 4.343 9 6 9 L 18 9 C 19.657 9 21 7.657 21 6 C 21 4.343 19.657 3 18 3 C 16.343 3 15 4.343 15 6",
IconName.Terminal => "M 4 17 L 10 11 L 4 5 M 12 19 L 20 19",
IconName.Code => "M 16 18 L 22 12 L 16 6 M 8 6 L 2 12 L 8 18",
- IconName.Moon => "M 12 3 A 6 6 0 0 0 21 12 A 9 9 0 1 1 12 3 Z",
- IconName.Sun => "M 16 12 A 4 4 0 1 1 8 12 A 4 4 0 1 1 16 12 Z M 12 2 L 12 4 M 12 20 L 12 22 M 4.93 4.93 L 6.34 6.34 M 17.66 17.66 L 19.07 19.07 M 2 12 L 4 12 M 20 12 L 22 12 M 6.34 17.66 L 4.93 19.07 M 19.07 4.93 L 17.66 6.34",
- IconName.Monitor => "M 4 3 L 20 3 A 2 2 0 0 1 22 5 L 22 15 A 2 2 0 0 1 20 17 L 4 17 A 2 2 0 0 1 2 15 L 2 5 A 2 2 0 0 1 4 3 Z M 8 21 L 16 21 M 12 17 L 12 21",
- IconName.Smartphone => "M 7 2 L 17 2 A 2 2 0 0 1 19 4 L 19 20 A 2 2 0 0 1 17 22 L 7 22 A 2 2 0 0 1 5 20 L 5 4 A 2 2 0 0 1 7 2 Z M 12 18 L 12.01 18",
- IconName.Laptop => "M 20 16 L 20 7 A 2 2 0 0 0 18 5 L 6 5 A 2 2 0 0 0 4 7 L 4 16 M 20 16 L 4 16 M 20 16 L 21.28 18.55 A 1 1 0 0 1 20.38 20 L 3.62 20 A 1 1 0 0 1 2.72 18.55 L 4 16",
- IconName.Wifi => "M 12 20 L 12.01 20 M 2 8.82 A 15 15 0 0 1 22 8.82 M 5 12.859 A 10 10 0 0 1 19 12.859 M 8.5 16.429 A 5 5 0 0 1 15.5 16.429",
- IconName.Camera => "M 14.5 4 L 9.5 4 L 7 7 L 4 7 A 2 2 0 0 0 2 9 L 2 18 A 2 2 0 0 0 4 20 L 20 20 A 2 2 0 0 0 22 18 L 22 9 A 2 2 0 0 0 20 7 L 17 7 L 14.5 4 Z M 15 13 A 3 3 0 1 1 9 13 A 3 3 0 1 1 15 13 Z",
- IconName.Mic => "M 12 2 A 3 3 0 0 0 9 5 L 9 12 A 3 3 0 0 0 15 12 L 15 5 A 3 3 0 0 0 12 2 Z M 19 10 L 19 12 A 7 7 0 0 1 5 12 L 5 10 M 12 19 L 12 22",
+ IconName.Moon => "M 12 3 C 9.515 5.485 9.515 9.515 12 12 C 14.485 14.485 18.515 14.485 21 12 C 21 16.971 16.971 21 12 21 C 7.029 21 3 16.971 3 12 C 3 7.029 7.029 3 12 3 Z",
+ IconName.Sun => "M 16 12 C 16 14.209 14.209 16 12 16 C 9.791 16 8 14.209 8 12 C 8 9.791 9.791 8 12 8 C 14.209 8 16 9.791 16 12 Z M 12 2 L 12 4 M 12 20 L 12 22 M 4.93 4.93 L 6.34 6.34 M 17.66 17.66 L 19.07 19.07 M 2 12 L 4 12 M 20 12 L 22 12 M 6.34 17.66 L 4.93 19.07 M 19.07 4.93 L 17.66 6.34",
+ IconName.Monitor => "M 4 3 L 20 3 C 21.105 3 22 3.895 22 5 L 22 15 C 22 16.105 21.105 17 20 17 L 4 17 C 2.895 17 2 16.105 2 15 L 2 5 C 2 3.895 2.895 3 4 3 Z M 8 21 L 16 21 M 12 17 L 12 21",
+ IconName.Smartphone => "M 7 2 L 17 2 C 18.105 2 19 2.895 19 4 L 19 20 C 19 21.105 18.105 22 17 22 L 7 22 C 5.895 22 5 21.105 5 20 L 5 4 C 5 2.895 5.895 2 7 2 Z M 12 18 L 12.01 18",
+ IconName.Laptop => "M 20 16 L 20 7 C 20 5.895 19.105 5 18 5 L 6 5 C 4.895 5 4 5.895 4 7 L 4 16 M 20 16 L 4 16 M 20 16 L 21.28 18.55 C 21.437 18.861 21.42 19.231 21.237 19.527 C 21.053 19.823 20.728 20.002 20.38 20 L 3.62 20 C 3.272 20.002 2.947 19.823 2.763 19.527 C 2.58 19.231 2.563 18.861 2.72 18.55 L 4 16",
+ IconName.Wifi => "M 12 20 L 12.01 20 M 2 8.82 C 7.694 3.727 16.306 3.727 22 8.82 M 5 12.859 C 8.888 9.048 15.112 9.048 19 12.859 M 8.5 16.429 C 10.444 14.523 13.556 14.523 15.5 16.429",
+ IconName.Camera => "M 14.5 4 L 9.5 4 L 7 7 L 4 7 C 2.895 7 2 7.895 2 9 L 2 18 C 2 19.105 2.895 20 4 20 L 20 20 C 21.105 20 22 19.105 22 18 L 22 9 C 22 7.895 21.105 7 20 7 L 17 7 L 14.5 4 Z M 15 13 C 15 14.657 13.657 16 12 16 C 10.343 16 9 14.657 9 13 C 9 11.343 10.343 10 12 10 C 13.657 10 15 11.343 15 13 Z",
+ IconName.Mic => "M 12 2 C 10.343 2 9 3.343 9 5 L 9 12 C 9 13.657 10.343 15 12 15 C 13.657 15 15 13.657 15 12 L 15 5 C 15 3.343 13.657 2 12 2 Z M 19 10 L 19 12 C 19 15.866 15.866 19 12 19 C 8.134 19 5 15.866 5 12 L 5 10 M 12 19 L 12 22",
IconName.Play => "M 6 3 L 20 12 L 6 21 L 6 3 Z",
- IconName.Pause => "M 15 4 L 17 4 A 1 1 0 0 1 18 5 L 18 19 A 1 1 0 0 1 17 20 L 15 20 A 1 1 0 0 1 14 19 L 14 5 A 1 1 0 0 1 15 4 Z M 7 4 L 9 4 A 1 1 0 0 1 10 5 L 10 19 A 1 1 0 0 1 9 20 L 7 20 A 1 1 0 0 1 6 19 L 6 5 A 1 1 0 0 1 7 4 Z",
- IconName.Volume2 => "M 11 4.702 A 0.705 0.705 0 0 0 9.797 4.204 L 6.413 7.587 A 1.4 1.4 0 0 1 5.416 8 L 3 8 A 1 1 0 0 0 2 9 L 2 15 A 1 1 0 0 0 3 16 L 5.416 16 A 1.4 1.4 0 0 1 6.413 16.413 L 9.796 19.797 A 0.705 0.705 0 0 0 11 19.298 Z M 16 9 A 5 5 0 0 1 16 15 M 19.364 18.364 A 9 9 0 0 0 19.364 5.636",
- IconName.Lightbulb => "M 15 14 C 15.2 13 15.7 12.3 16.5 11.5 C 17.5 10.6 18 9.3 18 8 A 6 6 0 0 0 6 8 C 6 9 6.2 10.2 7.5 11.5 C 8.2 12.2 8.8 13 9 14 M 9 18 L 15 18 M 10 22 L 14 22",
+ IconName.Pause => "M 15 4 L 17 4 C 17.552 4 18 4.448 18 5 L 18 19 C 18 19.552 17.552 20 17 20 L 15 20 C 14.448 20 14 19.552 14 19 L 14 5 C 14 4.448 14.448 4 15 4 Z M 7 4 L 9 4 C 9.552 4 10 4.448 10 5 L 10 19 C 10 19.552 9.552 20 9 20 L 7 20 C 6.448 20 6 19.552 6 19 L 6 5 C 6 4.448 6.448 4 7 4 Z",
+ IconName.Volume2 => "M 11 4.702 C 11 4.417 10.828 4.161 10.565 4.052 C 10.301 3.943 9.999 4.003 9.797 4.204 L 6.413 7.587 C 6.149 7.852 5.79 8.001 5.416 8 L 3 8 C 2.448 8 2 8.448 2 9 L 2 15 C 2 15.552 2.448 16 3 16 L 5.416 16 C 5.79 15.999 6.149 16.148 6.413 16.413 L 9.796 19.797 C 9.998 19.999 10.301 20.06 10.565 19.95 C 10.829 19.841 11 19.583 11 19.298 Z M 16 9 C 17.333 10.778 17.333 13.222 16 15 M 19.364 18.364 C 21.052 16.676 22 14.387 22 12 C 22 9.613 21.052 7.324 19.364 5.636",
+ IconName.Lightbulb => "M 15 14 C 15.2 13 15.7 12.3 16.5 11.5 C 17.5 10.6 18 9.3 18 8 C 18 4.686 15.314 2 12 2 C 8.686 2 6 4.686 6 8 C 6 9 6.2 10.2 7.5 11.5 C 8.2 12.2 8.8 13 9 14 M 9 18 L 15 18 M 10 22 L 14 22",
_ => string.Empty
};
}
diff --git a/examples/MAUI.Demo/Components/UI/Kbd.cs b/examples/MAUI.Demo/Components/UI/Kbd.cs
new file mode 100644
index 0000000..5f7ade0
--- /dev/null
+++ b/examples/MAUI.Demo/Components/UI/Kbd.cs
@@ -0,0 +1,49 @@
+using Microsoft.Maui.Controls.Shapes;
+
+namespace MAUI.Demo.Components.UI;
+
+// Keyboard key — h-5 rounded border bg-muted px-1.5 text-[11px] font-medium text-muted-foreground.
+// Usage:
+public partial class Kbd : ContentView
+{
+ public static readonly BindableProperty TextProperty =
+ BindableProperty.Create(nameof(Text), typeof(string), typeof(Kbd), string.Empty,
+ propertyChanged: (b, o, n) => ((Kbd)b)._label.Text = n as string ?? string.Empty);
+
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ private readonly Label _label;
+
+ public Kbd()
+ {
+ _label = new Label
+ {
+ FontSize = 11,
+ FontAttributes = FontAttributes.Bold,
+ HorizontalTextAlignment = TextAlignment.Center,
+ VerticalTextAlignment = TextAlignment.Center,
+ LineBreakMode = LineBreakMode.NoWrap
+ };
+ _label.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+
+ var key = new Border
+ {
+ Content = _label,
+ HeightRequest = 22,
+ MinimumWidthRequest = 22,
+ Padding = new Thickness(6, 0),
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusSm }
+ };
+ key.Token(VisualElement.BackgroundColorProperty, ShellToken.Muted);
+ key.Token(Border.StrokeProperty, ShellToken.Border);
+
+ Content = key;
+ HorizontalOptions = LayoutOptions.Start;
+ VerticalOptions = LayoutOptions.Center;
+ }
+}
diff --git a/examples/MAUI.Demo/Components/UI/LinkCard.cs b/examples/MAUI.Demo/Components/UI/LinkCard.cs
new file mode 100644
index 0000000..501c88d
--- /dev/null
+++ b/examples/MAUI.Demo/Components/UI/LinkCard.cs
@@ -0,0 +1,126 @@
+using Microsoft.Maui.Controls.Shapes;
+
+namespace MAUI.Demo.Components.UI;
+
+// Tappable card — rounded-lg border bg-card p-4, hover:bg-accent: an optional icon, a title, a
+// muted description and an arrow. Tapping raises Clicked and opens Url when one is set.
+// Usage:
+public partial class LinkCard : ContentView
+{
+ public static readonly BindableProperty TitleProperty =
+ BindableProperty.Create(nameof(Title), typeof(string), typeof(LinkCard), string.Empty,
+ propertyChanged: (b, o, n) => ((LinkCard)b).Update());
+
+ public static readonly BindableProperty DescriptionProperty =
+ BindableProperty.Create(nameof(Description), typeof(string), typeof(LinkCard), string.Empty,
+ propertyChanged: (b, o, n) => ((LinkCard)b).Update());
+
+ public static readonly BindableProperty IconProperty =
+ BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(LinkCard), IconName.None,
+ propertyChanged: (b, o, n) => ((LinkCard)b).Update());
+
+ // Opened in the system browser (or the app registered for the link) when the card is tapped.
+ public static readonly BindableProperty UrlProperty =
+ BindableProperty.Create(nameof(Url), typeof(string), typeof(LinkCard), string.Empty);
+
+ public string Title
+ {
+ get => (string)GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public string Description
+ {
+ get => (string)GetValue(DescriptionProperty);
+ set => SetValue(DescriptionProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => (IconName)GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public string Url
+ {
+ get => (string)GetValue(UrlProperty);
+ set => SetValue(UrlProperty, value);
+ }
+
+ public event EventHandler? Clicked;
+
+ private readonly Border _card;
+ private readonly Icon _icon;
+ private readonly Label _title;
+ private readonly Label _description;
+
+ public LinkCard()
+ {
+ _icon = new Icon { Size = 20, Token = ShellToken.MutedForeground, VerticalOptions = LayoutOptions.Start, Margin = new Thickness(0, 1, 0, 0) };
+ _title = new Label { FontSize = 15, FontAttributes = FontAttributes.Bold, LineBreakMode = LineBreakMode.TailTruncation };
+ _title.Token(Label.TextColorProperty, ShellToken.CardForeground);
+ _description = new Label { FontSize = 14 };
+ _description.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+ var arrow = new Icon { Name = IconName.ArrowUpRight, Size = 16, Token = ShellToken.MutedForeground, VerticalOptions = LayoutOptions.Start, Margin = new Thickness(0, 2, 0, 0) };
+
+ var row = new Grid
+ {
+ ColumnSpacing = 12,
+ ColumnDefinitions =
+ {
+ new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto)
+ }
+ };
+ row.Add(_icon, 0, 0);
+ row.Add(new VerticalStackLayout { Spacing = 4, Children = { _title, _description } }, 1, 0);
+ row.Add(arrow, 2, 0);
+
+ _card = new Border
+ {
+ Content = row,
+ Padding = new Thickness(16),
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusLg }
+ };
+ _card.Token(VisualElement.BackgroundColorProperty, ShellToken.Card);
+ _card.Token(Border.StrokeProperty, ShellToken.Border);
+
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => _card.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent);
+ pointer.PointerExited += (_, _) => _card.Token(VisualElement.BackgroundColorProperty, ShellToken.Card);
+ _card.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { ShellFocus.FocusPressed(this); Press(); };
+ _card.GestureRecognizers.Add(tap);
+ ShellFocus.MakeFocusable(this, Press);
+
+ Content = _card;
+ Update();
+ }
+
+ private async void Press()
+ {
+ if (!IsEnabled) return;
+ Clicked?.Invoke(this, EventArgs.Empty);
+ if (string.IsNullOrWhiteSpace(Url) || !Uri.TryCreate(Url, UriKind.Absolute, out var uri)) return;
+ try
+ {
+ await Launcher.Default.OpenAsync(uri);
+ }
+ catch (Exception)
+ {
+ // Nothing on the device can open this link.
+ }
+ }
+
+ private void Update()
+ {
+ _icon.Name = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ _title.Text = Title ?? string.Empty;
+ _title.IsVisible = !string.IsNullOrEmpty(Title);
+ _description.Text = Description ?? string.Empty;
+ _description.IsVisible = !string.IsNullOrEmpty(Description);
+ }
+}
diff --git a/examples/MAUI.Demo/Components/UI/MultiSelect.cs b/examples/MAUI.Demo/Components/UI/MultiSelect.cs
new file mode 100644
index 0000000..6163bd5
--- /dev/null
+++ b/examples/MAUI.Demo/Components/UI/MultiSelect.cs
@@ -0,0 +1,352 @@
+using System.Collections.Specialized;
+using Microsoft.Maui.Controls.Shapes;
+
+namespace MAUI.Demo.Components.UI;
+
+// Multi-select — a min-h-10 trigger that shows the picked options as removable chips, and a
+// floating panel with a search field and a checked list. Picking toggles an option and leaves
+// the panel open; click outside or press Escape to close it.
+// Usage: then
+// set ItemsSource in code or by binding.
+public partial class MultiSelect : ContentView, IShellPopup
+{
+ public static readonly BindableProperty ItemsSourceProperty =
+ BindableProperty.Create(nameof(ItemsSource), typeof(IList), typeof(MultiSelect), null,
+ propertyChanged: (b, o, n) => ((MultiSelect)b).RebuildItems());
+
+ public static readonly BindableProperty ValuesProperty =
+ BindableProperty.Create(nameof(Values), typeof(IList), typeof(MultiSelect), null, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((MultiSelect)b).OnValuesReplaced(o, n));
+
+ public static readonly BindableProperty PlaceholderProperty =
+ BindableProperty.Create(nameof(Placeholder), typeof(string), typeof(MultiSelect), "Select...",
+ propertyChanged: (b, o, n) => ((MultiSelect)b)._placeholder.Text = (string)n);
+
+ public static readonly BindableProperty SearchPlaceholderProperty =
+ BindableProperty.Create(nameof(SearchPlaceholder), typeof(string), typeof(MultiSelect), "Search...",
+ propertyChanged: (b, o, n) => ((MultiSelect)b)._search.Placeholder = (string)n);
+
+ public static readonly BindableProperty EmptyTextProperty =
+ BindableProperty.Create(nameof(EmptyText), typeof(string), typeof(MultiSelect), "No results found.",
+ propertyChanged: (b, o, n) => ((MultiSelect)b)._empty.Text = (string)n);
+
+ public IList? ItemsSource
+ {
+ get => (IList?)GetValue(ItemsSourceProperty);
+ set => SetValue(ItemsSourceProperty, value);
+ }
+
+ public IList? Values
+ {
+ get => (IList?)GetValue(ValuesProperty);
+ set => SetValue(ValuesProperty, value);
+ }
+
+ public string Placeholder
+ {
+ get => (string)GetValue(PlaceholderProperty);
+ set => SetValue(PlaceholderProperty, value);
+ }
+
+ public string SearchPlaceholder
+ {
+ get => (string)GetValue(SearchPlaceholderProperty);
+ set => SetValue(SearchPlaceholderProperty, value);
+ }
+
+ public string EmptyText
+ {
+ get => (string)GetValue(EmptyTextProperty);
+ set => SetValue(EmptyTextProperty, value);
+ }
+
+ public bool IsOpen { get; private set; }
+
+ public event EventHandler>? ValuesChanged;
+
+ private const double ItemHeight = 32;
+ private const double MaxListHeight = 240;
+
+ private readonly Border _trigger;
+ private readonly WrapLayout _chips;
+ private readonly Label _placeholder;
+ private readonly Border _panel;
+ private readonly Entry _search;
+ private readonly VerticalStackLayout _list;
+ private readonly Grid _listArea;
+ private readonly Label _empty;
+ private readonly List<(string Text, Border Row, Icon Check)> _rows = new();
+ private ShellPopupHandle? _handle;
+ private bool _chipTapped;
+
+ public MultiSelect()
+ {
+ _placeholder = new Label { Text = Placeholder, FontSize = 14, VerticalTextAlignment = TextAlignment.Center, VerticalOptions = LayoutOptions.Center, Margin = new Thickness(4, 0, 0, 0) };
+ _placeholder.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+ _chips = new WrapLayout { Spacing = 4, LineSpacing = 4, VerticalOptions = LayoutOptions.Center };
+
+ var triggerRow = new Grid
+ {
+ ColumnSpacing = 8,
+ ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto) }
+ };
+ triggerRow.Add(new Grid { Children = { _placeholder, _chips } }, 0, 0);
+ triggerRow.Add(new Icon { Name = IconName.ChevronsUpDown, Size = 16, Token = ShellToken.MutedForeground, VerticalOptions = LayoutOptions.Center }, 1, 0);
+
+ _trigger = new Border
+ {
+ Content = triggerRow,
+ MinimumHeightRequest = 40,
+ Padding = new Thickness(8, 6, 12, 6),
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ BackgroundColor = Colors.Transparent
+ };
+ _trigger.Token(Border.StrokeProperty, ShellToken.Input);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) =>
+ {
+ // A tap on a chip's × can reach the trigger too; it must not toggle the panel.
+ if (_chipTapped) { _chipTapped = false; return; }
+ if (!IsEnabled) return;
+ ShellFocus.FocusPressed(this);
+ SetOpen(!IsOpen);
+ };
+ _trigger.GestureRecognizers.Add(tap);
+ ShellFocus.MakeFocusable(this, () => { if (IsEnabled) SetOpen(!IsOpen); });
+
+ _search = new Entry
+ {
+ Placeholder = SearchPlaceholder,
+ FontSize = 14,
+ BackgroundColor = Colors.Transparent,
+ VerticalOptions = LayoutOptions.Center,
+ ClearButtonVisibility = ClearButtonVisibility.Never,
+ IsTextPredictionEnabled = false,
+ IsSpellCheckEnabled = false
+ };
+ _search.Token(Entry.TextColorProperty, ShellToken.PopoverForeground);
+ _search.Token(Entry.PlaceholderColorProperty, ShellToken.MutedForeground);
+ ShellPlatform.StripNativeChrome(_search);
+ ShellFocus.Track(_search);
+ _search.TextChanged += (_, _) => Filter();
+ // Enter toggles the first match.
+ _search.Completed += (_, _) =>
+ {
+ var first = _rows.FirstOrDefault(r => r.Row.IsVisible);
+ if (first.Row != null) Toggle(first.Text);
+ };
+
+ var searchRow = new Grid
+ {
+ HeightRequest = 40,
+ Padding = new Thickness(12, 0),
+ ColumnSpacing = 8,
+ ColumnDefinitions = { new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star) }
+ };
+ searchRow.Add(new Icon { Name = IconName.Search, Size = 16, Token = ShellToken.MutedForeground }, 0, 0);
+ searchRow.Add(_search, 1, 0);
+
+ var divider = new BoxView { HeightRequest = 1, BackgroundColor = Colors.Transparent };
+ divider.Token(BoxView.ColorProperty, ShellToken.Border);
+
+ _list = new VerticalStackLayout { Spacing = 0 };
+ _empty = new Label
+ {
+ Text = EmptyText,
+ FontSize = 14,
+ HorizontalOptions = LayoutOptions.Center,
+ VerticalOptions = LayoutOptions.Center,
+ IsVisible = false
+ };
+ _empty.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+ // Fixed height (set in RebuildItems): the panel keeps its size and position while filtering.
+ _listArea = new Grid { Margin = new Thickness(4), Children = { new ScrollView { Content = _list }, _empty } };
+
+ _panel = new Border
+ {
+ Content = new VerticalStackLayout { Spacing = 0, Children = { searchRow, divider, _listArea } },
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ Shadow = ShellPopups.PanelShadow()
+ };
+ _panel.Token(VisualElement.BackgroundColorProperty, ShellToken.Popover);
+ _panel.Token(Border.StrokeProperty, ShellToken.Border);
+ // Taps on the panel's own chrome must not fall through to the click-outside catcher.
+ _panel.GestureRecognizers.Add(new TapGestureRecognizer());
+
+ Content = _trigger;
+ RebuildItems();
+ RebuildChips();
+ }
+
+ private IReadOnlyList Current => (IReadOnlyList?)Values?.ToList() ?? Array.Empty();
+
+ public void SetOpen(bool open)
+ {
+ if (IsOpen == open) return;
+ IsOpen = open;
+ _trigger.Token(Border.StrokeProperty, open ? ShellToken.Ring : ShellToken.Input);
+ if (open)
+ {
+ _search.Text = string.Empty;
+ Filter();
+ ShellPopups.Opened(this);
+ _handle = ShellPortal.ShowPopup(_trigger, _panel, new ShellPopupOptions
+ {
+ MatchAnchorWidth = true,
+ Owner = this,
+ OnDismiss = Close
+ });
+ // Desktop only: on touch the soft keyboard would cover the list.
+ if (DeviceInfo.Idiom == DeviceIdiom.Desktop)
+ Dispatcher.DispatchDelayed(TimeSpan.FromMilliseconds(150), () => { if (IsOpen) _search.Focus(); });
+ }
+ else
+ {
+ ShellPopups.Closed(this);
+ if (_search.IsFocused) _search.Unfocus();
+ var handle = _handle;
+ _handle = null;
+ if (handle != null) _ = handle.CloseAsync();
+ }
+ }
+
+ public void Close() => SetOpen(false);
+
+ // Adds the option when it isn't picked, removes it when it is.
+ public void Toggle(string text)
+ {
+ // A new list each time, so a two-way binding sees the change.
+ var next = new List(Current);
+ if (!next.Remove(text)) next.Add(text);
+ Values = next;
+ }
+
+ private void OnValuesReplaced(object? oldValue, object? newValue)
+ {
+ if (oldValue is INotifyCollectionChanged before) before.CollectionChanged -= OnValuesMutated;
+ if (newValue is INotifyCollectionChanged after) after.CollectionChanged += OnValuesMutated;
+ OnValuesMutated(this, null);
+ }
+
+ private void OnValuesMutated(object? sender, NotifyCollectionChangedEventArgs? e)
+ {
+ RebuildChips();
+ UpdateChecks();
+ ValuesChanged?.Invoke(this, Current);
+ }
+
+ private void RebuildChips()
+ {
+ _chips.Children.Clear();
+ var current = Current;
+ foreach (var value in current)
+ _chips.Children.Add(CreateChip(value));
+ _placeholder.IsVisible = current.Count == 0;
+ }
+
+ private View CreateChip(string value)
+ {
+ var label = new Label { Text = value, FontSize = 12, FontAttributes = FontAttributes.Bold, VerticalTextAlignment = TextAlignment.Center, LineBreakMode = LineBreakMode.TailTruncation };
+ label.Token(Label.TextColorProperty, ShellToken.SecondaryForeground);
+ var close = new Icon { Name = IconName.X, Size = 12, Token = ShellToken.MutedForeground, HorizontalOptions = LayoutOptions.Center, VerticalOptions = LayoutOptions.Center };
+ // 12px icon in a 20px tap target.
+ var remove = new Grid { WidthRequest = 20, HeightRequest = 20, BackgroundColor = Colors.Transparent, Children = { close } };
+ SemanticProperties.SetDescription(remove, $"Remove {value}");
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => close.Token = ShellToken.Destructive;
+ pointer.PointerExited += (_, _) => close.Token = ShellToken.MutedForeground;
+ remove.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) =>
+ {
+ if (!IsEnabled) return;
+ _chipTapped = true;
+ // Cleared on the next turn in case the tap never reaches the trigger.
+ Dispatcher.Dispatch(() => _chipTapped = false);
+ Toggle(value);
+ };
+ remove.GestureRecognizers.Add(tap);
+
+ var chip = new Border
+ {
+ Content = new HorizontalStackLayout { Spacing = 2, Children = { label, remove } },
+ Padding = new Thickness(8, 0, 2, 0),
+ HeightRequest = 24,
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }
+ };
+ chip.Token(VisualElement.BackgroundColorProperty, ShellToken.Secondary);
+ return chip;
+ }
+
+ private void RebuildItems()
+ {
+ _list.Children.Clear();
+ _rows.Clear();
+ var items = ItemsSource;
+ _listArea.HeightRequest = Math.Min(Math.Max(items?.Count ?? 0, 2) * ItemHeight, MaxListHeight);
+ if (items != null)
+ {
+ foreach (var text in items)
+ _list.Children.Add(CreateItem(text));
+ }
+ UpdateChecks();
+ Filter();
+ }
+
+ private View CreateItem(string text)
+ {
+ var check = new Icon { Name = IconName.Check, Size = 16, Opacity = 0 };
+ var label = new Label { Text = text, FontSize = 14, VerticalOptions = LayoutOptions.Center, VerticalTextAlignment = TextAlignment.Center, LineBreakMode = LineBreakMode.TailTruncation };
+ label.Token(Label.TextColorProperty, ShellToken.PopoverForeground);
+ var row = new Grid
+ {
+ ColumnSpacing = 8,
+ ColumnDefinitions = { new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star) }
+ };
+ row.Add(check, 0, 0);
+ row.Add(label, 1, 0);
+
+ var item = new Border
+ {
+ Content = row,
+ HeightRequest = ItemHeight,
+ Padding = new Thickness(8, 0),
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusSm },
+ BackgroundColor = Colors.Transparent
+ };
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => item.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent);
+ pointer.PointerExited += (_, _) => { item.ClearValue(VisualElement.BackgroundColorProperty); item.BackgroundColor = Colors.Transparent; };
+ item.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => Toggle(text);
+ item.GestureRecognizers.Add(tap);
+
+ _rows.Add((text, item, check));
+ return item;
+ }
+
+ private void UpdateChecks()
+ {
+ var current = Current;
+ // The check keeps its column when hidden, so option labels stay aligned.
+ foreach (var (text, _, check) in _rows)
+ check.Opacity = current.Contains(text) ? 1 : 0;
+ }
+
+ private void Filter()
+ {
+ var query = _search.Text?.Trim() ?? string.Empty;
+ var any = false;
+ foreach (var (text, row, _) in _rows)
+ {
+ row.IsVisible = query.Length == 0 || text.Contains(query, StringComparison.OrdinalIgnoreCase);
+ any |= row.IsVisible;
+ }
+ _empty.IsVisible = !any;
+ }
+}
diff --git a/examples/MAUI.Demo/Components/UI/NumberInput.cs b/examples/MAUI.Demo/Components/UI/NumberInput.cs
new file mode 100644
index 0000000..549e288
--- /dev/null
+++ b/examples/MAUI.Demo/Components/UI/NumberInput.cs
@@ -0,0 +1,202 @@
+using System.Globalization;
+using Microsoft.Maui.Controls.Shapes;
+
+namespace MAUI.Demo.Components.UI;
+
+// Number field with − and + buttons — h-10 rounded-md border, the value centered between them.
+// The value is clamped to Minimum / Maximum; the buttons dim at the limits.
+// Usage:
+public partial class NumberInput : ContentView
+{
+ public static readonly BindableProperty ValueProperty =
+ BindableProperty.Create(nameof(Value), typeof(decimal?), typeof(NumberInput), null, BindingMode.TwoWay,
+ coerceValue: (b, v) => ((NumberInput)b).Clamp((decimal?)v),
+ propertyChanged: (b, o, n) => ((NumberInput)b).OnValueChanged());
+
+ public static readonly BindableProperty MinimumProperty =
+ BindableProperty.Create(nameof(Minimum), typeof(decimal?), typeof(NumberInput), null,
+ propertyChanged: (b, o, n) => ((NumberInput)b).OnLimitsChanged());
+
+ public static readonly BindableProperty MaximumProperty =
+ BindableProperty.Create(nameof(Maximum), typeof(decimal?), typeof(NumberInput), null,
+ propertyChanged: (b, o, n) => ((NumberInput)b).OnLimitsChanged());
+
+ public static readonly BindableProperty StepProperty =
+ BindableProperty.Create(nameof(Step), typeof(decimal), typeof(NumberInput), 1m);
+
+ public static readonly BindableProperty PlaceholderProperty =
+ BindableProperty.Create(nameof(Placeholder), typeof(string), typeof(NumberInput), string.Empty,
+ propertyChanged: (b, o, n) => ((NumberInput)b)._entry.Placeholder = (string)n);
+
+ public decimal? Value
+ {
+ get => (decimal?)GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public decimal? Minimum
+ {
+ get => (decimal?)GetValue(MinimumProperty);
+ set => SetValue(MinimumProperty, value);
+ }
+
+ public decimal? Maximum
+ {
+ get => (decimal?)GetValue(MaximumProperty);
+ set => SetValue(MaximumProperty, value);
+ }
+
+ public decimal Step
+ {
+ get => (decimal)GetValue(StepProperty);
+ set => SetValue(StepProperty, value);
+ }
+
+ public string Placeholder
+ {
+ get => (string)GetValue(PlaceholderProperty);
+ set => SetValue(PlaceholderProperty, value);
+ }
+
+ public event EventHandler? ValueChanged;
+
+ private readonly Border _frame;
+ private readonly Entry _entry;
+ private readonly Grid _decrease;
+ private readonly Grid _increase;
+
+ public NumberInput()
+ {
+ _entry = new Entry
+ {
+ Keyboard = Keyboard.Numeric,
+ FontSize = 14,
+ HorizontalTextAlignment = TextAlignment.Center,
+ VerticalOptions = LayoutOptions.Center,
+ BackgroundColor = Colors.Transparent
+ };
+ _entry.Token(Entry.TextColorProperty, ShellToken.Foreground);
+ _entry.Token(Entry.PlaceholderColorProperty, ShellToken.MutedForeground);
+ ShellPlatform.StripNativeChrome(_entry);
+ ShellFocus.Track(_entry);
+ _entry.Focused += (_, _) => _frame!.Token(Border.StrokeProperty, ShellToken.Ring);
+ _entry.Unfocused += (_, _) => { _frame!.Token(Border.StrokeProperty, ShellToken.Input); Commit(); };
+ _entry.Completed += (_, _) => Commit();
+
+ _decrease = StepButton(IconName.Minus, "Decrease", -1);
+ _increase = StepButton(IconName.Plus, "Increase", 1);
+
+ var row = new Grid
+ {
+ ColumnDefinitions =
+ {
+ new ColumnDefinition(40), new ColumnDefinition(1), new ColumnDefinition(GridLength.Star),
+ new ColumnDefinition(1), new ColumnDefinition(40)
+ }
+ };
+ row.Add(_decrease, 0, 0);
+ row.Add(Divider(), 1, 0);
+ row.Add(_entry, 2, 0);
+ row.Add(Divider(), 3, 0);
+ row.Add(_increase, 4, 0);
+
+ _frame = new Border
+ {
+ Content = row,
+ HeightRequest = 40,
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ BackgroundColor = Colors.Transparent
+ };
+ _frame.Token(Border.StrokeProperty, ShellToken.Input);
+ Content = _frame;
+ UpdateText();
+ UpdateButtons();
+ }
+
+ private static BoxView Divider()
+ {
+ var line = new BoxView { BackgroundColor = Colors.Transparent };
+ line.Token(BoxView.ColorProperty, ShellToken.Input);
+ return line;
+ }
+
+ private Grid StepButton(IconName icon, string description, int direction)
+ {
+ var glyph = new Icon { Name = icon, Size = 16, Token = ShellToken.MutedForeground, HorizontalOptions = LayoutOptions.Center, VerticalOptions = LayoutOptions.Center };
+ var button = new Grid { BackgroundColor = Colors.Transparent, Children = { glyph } };
+ SemanticProperties.SetDescription(button, description);
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => { if (button.Opacity > 0.9) { button.Token(VisualElement.BackgroundColorProperty, ShellToken.Muted); glyph.Token = ShellToken.Foreground; } };
+ pointer.PointerExited += (_, _) =>
+ {
+ button.ClearValue(VisualElement.BackgroundColorProperty);
+ button.BackgroundColor = Colors.Transparent;
+ glyph.Token = ShellToken.MutedForeground;
+ };
+ button.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) =>
+ {
+ if (!IsEnabled) return;
+ Commit();
+ Value = (Value ?? Minimum ?? 0) + direction * Step;
+ };
+ button.GestureRecognizers.Add(tap);
+ return button;
+ }
+
+ private decimal? Clamp(decimal? value)
+ {
+ if (value is not { } number) return null;
+ if (Minimum is { } min && number < min) number = min;
+ if (Maximum is { } max && number > max) number = max;
+ return number;
+ }
+
+ // A typed number is clamped and kept, empty clears the value, anything else is reverted.
+ private void Commit()
+ {
+ var text = _entry.Text?.Trim();
+ if (string.IsNullOrEmpty(text)) Value = null;
+ else if (decimal.TryParse(text, NumberStyles.Number, CultureInfo.CurrentCulture, out var typed) ||
+ decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out typed))
+ Value = typed;
+ UpdateText();
+ }
+
+ private void OnLimitsChanged()
+ {
+ Value = Clamp(Value);
+ UpdateButtons();
+ }
+
+ private void OnValueChanged()
+ {
+ UpdateText();
+ UpdateButtons();
+ ValueChanged?.Invoke(this, Value);
+ }
+
+ private void UpdateText()
+ {
+ var text = Value?.ToString(CultureInfo.CurrentCulture) ?? string.Empty;
+ if (_entry.Text != text) _entry.Text = text;
+ }
+
+ private void UpdateButtons()
+ {
+ _decrease.Opacity = Value is { } v && Minimum is { } min && v <= min ? 0.4 : 1;
+ _increase.Opacity = Value is { } w && Maximum is { } max && w >= max ? 0.4 : 1;
+ }
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == IsEnabledProperty.PropertyName && _entry != null)
+ {
+ Opacity = IsEnabled ? 1.0 : 0.5;
+ _entry.IsEnabled = IsEnabled;
+ }
+ }
+}
diff --git a/examples/MAUI.Demo/Components/UI/Shell.cs b/examples/MAUI.Demo/Components/UI/Shell.cs
index ee882cc..adb6fe0 100644
--- a/examples/MAUI.Demo/Components/UI/Shell.cs
+++ b/examples/MAUI.Demo/Components/UI/Shell.cs
@@ -365,9 +365,9 @@ public static void BlurActiveInput()
}
// Page-level layer for anything that must draw above the page: modal overlays, floating panels,
-// tooltips, toasts — the MAUI take on a React portal. Created on first use as the last child of
-// the page's root Grid (a non-Grid root is wrapped in one, once). The layer itself never takes
-// input; only what is placed in it does.
+// tooltips, toasts — the MAUI take on a React portal. Set up as the page appears: the page content
+// is wrapped in a Grid, once, and the layer is that Grid's last child. The layer itself never
+// takes input; only what is placed in it does.
public static class ShellPortal
{
private static readonly BindableProperty LayerProperty =
@@ -389,20 +389,17 @@ public static class ShellPortal
if (page is null) return null;
if (page.GetValue(LayerProperty) is Grid existing) return existing;
- if (page.Content is not Grid root)
- {
- var content = page.Content;
- var scroll = content as ScrollView;
- var (scrollX, scrollY) = (scroll?.ScrollX ?? 0, scroll?.ScrollY ?? 0);
- // Edge-to-edge like the page itself, so wrapping doesn't change how the content is inset.
- root = new Grid { SafeAreaEdges = SafeAreaEdges.None };
- page.Content = root;
- if (content != null) root.Children.Add(content);
- // Normally this runs as the page appears (see ShellTheme.EnsureInitialized); if it
- // runs later, re-parenting must not lose where the user had scrolled to.
- if (scroll != null && (scrollX > 0 || scrollY > 0))
- scroll.Dispatcher.Dispatch(() => _ = scroll.ScrollToAsync(scrollX, scrollY, false));
- }
+ // Always wrapped, even a Grid root: the wrapper spans the window while the content keeps
+ // its own safe-area insets, so the layer can reach under the system bars.
+ var content = page.Content;
+ var scroll = content as ScrollView;
+ var (scrollX, scrollY) = (scroll?.ScrollX ?? 0, scroll?.ScrollY ?? 0);
+ var root = new Grid { SafeAreaEdges = SafeAreaEdges.None };
+ page.Content = root;
+ if (content != null) root.Children.Add(content);
+ // Re-parenting must not lose the scroll position if this runs after the page appeared.
+ if (scroll != null && (scrollX > 0 || scrollY > 0))
+ scroll.Dispatcher.Dispatch(() => _ = scroll.ScrollToAsync(scrollX, scrollY, false));
// The layer covers the whole window (backdrops dim under the system bars); content placed
// in it uses GetSafeInsets to stay clear of them.
@@ -413,8 +410,6 @@ public static class ShellPortal
CascadeInputTransparent = false,
SafeAreaEdges = SafeAreaEdges.None
};
- Grid.SetRowSpan(layer, Math.Max(1, root.RowDefinitions.Count));
- Grid.SetColumnSpan(layer, Math.Max(1, root.ColumnDefinitions.Count));
root.Children.Add(layer);
page.SetValue(LayerProperty, layer);
#if ANDROID
@@ -610,7 +605,10 @@ public static Point GetPosition(VisualElement view, Grid layer)
panel.BindingContext = (options.Owner ?? anchor).BindingContext;
panel.HorizontalOptions = LayoutOptions.Start;
panel.VerticalOptions = LayoutOptions.Start;
- panel.Margin = new Thickness(0);
+ // Start inside the safe area: a panel first laid out under the status bar gets padded
+ // for it, and keeps that padding after it is moved into place.
+ var inset = GetSafeInsets(layer);
+ panel.Margin = new Thickness(inset.Left + 8, inset.Top + 8, 0, 0);
panel.Opacity = 0;
panel.IsVisible = true;
if (options.MatchAnchorWidth) panel.WidthRequest = anchor.Width;
@@ -630,6 +628,13 @@ private static async Task PlaceAsync(View anchor, View panel, Grid layer, ShellP
var size = new Size(panel.Width, panel.Height);
var origin = GetPosition(anchor, layer);
+ var anchorSize = new Size(anchor.Width, anchor.Height);
+ if (options.AnchorPoint is { } point)
+ {
+ // Open at a point inside the anchor (context menus): a zero-size anchor there.
+ origin = new Point(origin.X + point.X, origin.Y + point.Y);
+ anchorSize = Size.Zero;
+ }
const double edge = 8;
var safe = GetSafeInsets(layer);
var minTop = safe.Top + edge;
@@ -637,11 +642,11 @@ private static async Task PlaceAsync(View anchor, View panel, Grid layer, ShellP
var x = options.Align switch
{
- ShellPopupAlign.Center => origin.X + (anchor.Width - size.Width) / 2,
- ShellPopupAlign.End => origin.X + anchor.Width - size.Width,
+ ShellPopupAlign.Center => origin.X + (anchorSize.Width - size.Width) / 2,
+ ShellPopupAlign.End => origin.X + anchorSize.Width - size.Width,
_ => origin.X
};
- var below = origin.Y + anchor.Height + options.Offset;
+ var below = origin.Y + anchorSize.Height + options.Offset;
var above = origin.Y - size.Height - options.Offset;
var fitsBelow = below + size.Height <= maxBottom;
var fitsAbove = above >= minTop;
@@ -670,6 +675,8 @@ public sealed class ShellPopupOptions
public ShellPopupAlign Align { get; init; } = ShellPopupAlign.Start;
public double Offset { get; init; } = 4;
public bool MatchAnchorWidth { get; init; }
+ // Position inside the anchor to open at instead of its edge (e.g. where the user right-clicked).
+ public Point? AnchorPoint { get; init; }
// Modal popups get a click-outside catcher (menus, selects); tooltips and hover cards don't.
public bool Modal { get; init; } = true;
public Action? OnDismiss { get; init; }
@@ -850,6 +857,8 @@ protected ShellPopoverHost()
protected virtual bool Modal => true;
protected virtual ShellPopupPlacement Placement => ShellPopupPlacement.Bottom;
protected virtual ShellPopupAlign Align => ShellPopupAlign.Start;
+ protected virtual Point? AnchorPoint => null;
+ protected virtual double Offset => 4;
protected View? PopupContent => _content;
@@ -878,6 +887,8 @@ private void OnOpenChanged()
{
Placement = Placement,
Align = Align,
+ Offset = Offset,
+ AnchorPoint = AnchorPoint,
Modal = Modal,
Owner = this,
OnDismiss = Close
diff --git a/examples/MAUI.Demo/Components/UI/StatCard.cs b/examples/MAUI.Demo/Components/UI/StatCard.cs
new file mode 100644
index 0000000..4506dca
--- /dev/null
+++ b/examples/MAUI.Demo/Components/UI/StatCard.cs
@@ -0,0 +1,157 @@
+using Microsoft.Maui.Controls.Shapes;
+
+namespace MAUI.Demo.Components.UI;
+
+// Dashboard metric — rounded-lg border bg-card p-5: a muted title with an optional icon, the
+// value in text-2xl bold, a change pill tinted by trend, and an optional description.
+// Usage:
+public partial class StatCard : ContentView
+{
+ public static readonly BindableProperty TitleProperty =
+ BindableProperty.Create(nameof(Title), typeof(string), typeof(StatCard), string.Empty,
+ propertyChanged: (b, o, n) => ((StatCard)b).Update());
+
+ public static readonly BindableProperty ValueProperty =
+ BindableProperty.Create(nameof(Value), typeof(string), typeof(StatCard), string.Empty,
+ propertyChanged: (b, o, n) => ((StatCard)b).Update());
+
+ public static readonly BindableProperty ChangeProperty =
+ BindableProperty.Create(nameof(Change), typeof(string), typeof(StatCard), string.Empty,
+ propertyChanged: (b, o, n) => ((StatCard)b).Update());
+
+ public static readonly BindableProperty TrendProperty =
+ BindableProperty.Create(nameof(Trend), typeof(StatTrend), typeof(StatCard), StatTrend.Neutral,
+ propertyChanged: (b, o, n) => ((StatCard)b).Update());
+
+ public static readonly BindableProperty DescriptionProperty =
+ BindableProperty.Create(nameof(Description), typeof(string), typeof(StatCard), string.Empty,
+ propertyChanged: (b, o, n) => ((StatCard)b).Update());
+
+ public static readonly BindableProperty IconProperty =
+ BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(StatCard), IconName.None,
+ propertyChanged: (b, o, n) => ((StatCard)b).Update());
+
+ public string Title
+ {
+ get => (string)GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public string Value
+ {
+ get => (string)GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public string Change
+ {
+ get => (string)GetValue(ChangeProperty);
+ set => SetValue(ChangeProperty, value);
+ }
+
+ public StatTrend Trend
+ {
+ get => (StatTrend)GetValue(TrendProperty);
+ set => SetValue(TrendProperty, value);
+ }
+
+ public string Description
+ {
+ get => (string)GetValue(DescriptionProperty);
+ set => SetValue(DescriptionProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => (IconName)GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ private readonly Label _title;
+ private readonly Icon _icon;
+ private readonly Label _value;
+ private readonly Border _pill;
+ private readonly BoxView _pillTint;
+ private readonly Label _change;
+ private readonly Label _description;
+
+ public StatCard()
+ {
+ _title = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold, VerticalTextAlignment = TextAlignment.Center, LineBreakMode = LineBreakMode.TailTruncation };
+ _title.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+ _icon = new Icon { Size = 16, Token = ShellToken.MutedForeground, VerticalOptions = LayoutOptions.Center };
+ var header = new Grid
+ {
+ ColumnSpacing = 8,
+ ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto) }
+ };
+ 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);
+
+ // The pill is the trend color at low strength behind text in the full color.
+ _pillTint = new BoxView { BackgroundColor = Colors.Transparent };
+ _change = new Label { FontSize = 12, FontAttributes = FontAttributes.Bold, Margin = new Thickness(6, 2), VerticalTextAlignment = TextAlignment.Center };
+ _pill = new Border
+ {
+ Content = new Grid { Children = { _pillTint, _change } },
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ VerticalOptions = LayoutOptions.Center,
+ BackgroundColor = Colors.Transparent
+ };
+
+ _description = new Label { FontSize = 12 };
+ _description.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+
+ var card = new Border
+ {
+ Content = new VerticalStackLayout
+ {
+ Spacing = 0,
+ Children =
+ {
+ header,
+ new HorizontalStackLayout { Spacing = 8, Margin = new Thickness(0, 8, 0, 0), Children = { _value, _pill } },
+ _description
+ }
+ },
+ 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;
+ Update();
+ }
+
+ private void Update()
+ {
+ _title.Text = Title ?? string.Empty;
+ _icon.Name = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ _value.Text = Value ?? string.Empty;
+
+ _change.Text = Change ?? string.Empty;
+ _pill.IsVisible = !string.IsNullOrEmpty(Change);
+ var (tint, strength, text) = Trend switch
+ {
+ StatTrend.Up => (ShellToken.Success, 0.12, ShellToken.Success),
+ StatTrend.Down => (ShellToken.Destructive, 0.12, ShellToken.Destructive),
+ _ => (ShellToken.Muted, 1.0, ShellToken.MutedForeground)
+ };
+ _pillTint.Token(BoxView.ColorProperty, tint);
+ _pillTint.Opacity = strength;
+ _change.Token(Label.TextColorProperty, text);
+
+ _description.Text = Description ?? string.Empty;
+ _description.IsVisible = !string.IsNullOrEmpty(Description);
+ _description.Margin = new Thickness(0, 4, 0, 0);
+ }
+}
+
+public enum StatTrend { Neutral, Up, Down }
diff --git a/examples/MAUI.Demo/Components/UI/Stepper.cs b/examples/MAUI.Demo/Components/UI/Stepper.cs
new file mode 100644
index 0000000..d1512e6
--- /dev/null
+++ b/examples/MAUI.Demo/Components/UI/Stepper.cs
@@ -0,0 +1,279 @@
+using System.Collections.ObjectModel;
+using Microsoft.Maui.Controls.Shapes;
+using MAUI.Demo.Components.UI.Variants;
+
+namespace MAUI.Demo.Components.UI;
+
+// Step-by-step flow — numbered circles joined by lines (active filled, completed checked), the
+// active step's content, and Previous / Next / Confirm buttons. A step becomes clickable once it
+// has been reached.
+//
+// ...any view...
+// ...
+// ...
+//
+[ContentProperty(nameof(Steps))]
+public partial class Stepper : ContentView
+{
+ public static readonly BindableProperty CurrentStepProperty =
+ BindableProperty.Create(nameof(CurrentStep), typeof(int), typeof(Stepper), 0, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((Stepper)b).OnCurrentStepChanged());
+
+ public static readonly BindableProperty ShowNavigationProperty =
+ BindableProperty.Create(nameof(ShowNavigation), typeof(bool), typeof(Stepper), true,
+ propertyChanged: (b, o, n) => ((Stepper)b).UpdateState());
+
+ public static readonly BindableProperty PreviousTextProperty =
+ BindableProperty.Create(nameof(PreviousText), typeof(string), typeof(Stepper), "Previous",
+ propertyChanged: (b, o, n) => ((Stepper)b).UpdateState());
+
+ public static readonly BindableProperty NextTextProperty =
+ BindableProperty.Create(nameof(NextText), typeof(string), typeof(Stepper), "Next",
+ propertyChanged: (b, o, n) => ((Stepper)b).UpdateState());
+
+ public static readonly BindableProperty ConfirmTextProperty =
+ BindableProperty.Create(nameof(ConfirmText), typeof(string), typeof(Stepper), "Confirm",
+ propertyChanged: (b, o, n) => ((Stepper)b).UpdateState());
+
+ public int CurrentStep
+ {
+ get => (int)GetValue(CurrentStepProperty);
+ set => SetValue(CurrentStepProperty, value);
+ }
+
+ public bool ShowNavigation
+ {
+ get => (bool)GetValue(ShowNavigationProperty);
+ set => SetValue(ShowNavigationProperty, value);
+ }
+
+ public string PreviousText
+ {
+ get => (string)GetValue(PreviousTextProperty);
+ set => SetValue(PreviousTextProperty, value);
+ }
+
+ public string NextText
+ {
+ get => (string)GetValue(NextTextProperty);
+ set => SetValue(NextTextProperty, value);
+ }
+
+ public string ConfirmText
+ {
+ get => (string)GetValue(ConfirmTextProperty);
+ set => SetValue(ConfirmTextProperty, value);
+ }
+
+ public IList Steps => _steps;
+
+ public event EventHandler? CurrentStepChanged;
+ // Raised by the Confirm button on the last step.
+ public event EventHandler? Confirmed;
+
+ private const double CircleSize = 32;
+
+ private sealed class Marker(Border circle, Label number, Icon check, Label title, BoxView before, BoxView after)
+ {
+ public Border Circle { get; } = circle;
+ public Label Number { get; } = number;
+ public Icon Check { get; } = check;
+ public Label Title { get; } = title;
+ public BoxView Before { get; } = before;
+ public BoxView After { get; } = after;
+ }
+
+ private readonly ObservableCollection _steps = new();
+ private readonly List _markers = new();
+ private readonly Grid _header;
+ private readonly ContentView _body;
+ private readonly Grid _navigation;
+ private readonly Button _previous;
+ private readonly Button _next;
+ private int _highest;
+
+ public Stepper()
+ {
+ _header = new Grid();
+ _body = new ContentView { Margin = new Thickness(0, 24, 0, 0) };
+
+ _previous = new Button { Variant = ButtonVariant.Outline, HorizontalOptions = LayoutOptions.Start };
+ _previous.Clicked += (_, _) => Previous();
+ _next = new Button { HorizontalOptions = LayoutOptions.End };
+ _next.Clicked += (_, _) =>
+ {
+ if (IsLast)
+ {
+ _highest = Math.Max(_highest, _steps.Count - 1);
+ Confirmed?.Invoke(this, EventArgs.Empty);
+ }
+ else Next();
+ };
+ _navigation = new Grid { Margin = new Thickness(0, 24, 0, 0), Children = { _previous, _next } };
+
+ Content = new VerticalStackLayout { Spacing = 0, Children = { _header, _body, _navigation } };
+ _steps.CollectionChanged += (_, _) => Rebuild();
+ Rebuild();
+ }
+
+ private int Current => Math.Clamp(CurrentStep, 0, Math.Max(0, _steps.Count - 1));
+ private bool IsLast => Current >= _steps.Count - 1;
+
+ public void Next()
+ {
+ if (!IsLast) CurrentStep = Current + 1;
+ }
+
+ public void Previous()
+ {
+ if (Current > 0) CurrentStep = Current - 1;
+ }
+
+ private void OnCurrentStepChanged()
+ {
+ _highest = Math.Max(_highest, Current);
+ UpdateState();
+ CurrentStepChanged?.Invoke(this, Current);
+ }
+
+ private void Rebuild()
+ {
+ _header.Children.Clear();
+ _header.ColumnDefinitions.Clear();
+ _markers.Clear();
+
+ for (var i = 0; i < _steps.Count; i++)
+ {
+ var index = i;
+ var step = _steps[i];
+ step.PropertyChanged -= OnStepPropertyChanged;
+ step.PropertyChanged += OnStepPropertyChanged;
+
+ var number = new Label
+ {
+ Text = (i + 1).ToString(),
+ FontSize = 14,
+ FontAttributes = FontAttributes.Bold,
+ HorizontalTextAlignment = TextAlignment.Center,
+ VerticalTextAlignment = TextAlignment.Center
+ };
+ var check = new Icon { Name = IconName.Check, Size = 16, Token = ShellToken.Primary, HorizontalOptions = LayoutOptions.Center, VerticalOptions = LayoutOptions.Center };
+ var circle = new Border
+ {
+ Content = new Grid { Children = { number, check } },
+ WidthRequest = CircleSize,
+ HeightRequest = CircleSize,
+ StrokeThickness = 2,
+ StrokeShape = new RoundRectangle { CornerRadius = CircleSize / 2 }
+ };
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { if (index <= _highest) CurrentStep = index; };
+ circle.GestureRecognizers.Add(tap);
+
+ // Lines run from each circle to the edges of its column, so neighbors meet.
+ var before = Line(i > 0);
+ var after = Line(i < _steps.Count - 1);
+ var rail = new Grid
+ {
+ ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star) }
+ };
+ rail.Add(before, 0, 0);
+ rail.Add(circle, 1, 0);
+ rail.Add(after, 2, 0);
+
+ var title = new Label
+ {
+ Text = step.Title,
+ FontSize = 14,
+ FontAttributes = FontAttributes.Bold,
+ HorizontalTextAlignment = TextAlignment.Center,
+ Margin = new Thickness(4, 8, 4, 0),
+ IsVisible = !string.IsNullOrEmpty(step.Title)
+ };
+ var description = new Label
+ {
+ Text = step.Description,
+ FontSize = 12,
+ HorizontalTextAlignment = TextAlignment.Center,
+ Margin = new Thickness(4, 2, 4, 0),
+ IsVisible = !string.IsNullOrEmpty(step.Description)
+ };
+ description.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+
+ _header.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
+ _header.Add(new VerticalStackLayout { Spacing = 0, Children = { rail, title, description } }, i, 0);
+ _markers.Add(new Marker(circle, number, check, title, before, after));
+ }
+
+ _highest = Math.Min(_highest, Math.Max(0, _steps.Count - 1));
+ UpdateState();
+
+ static BoxView Line(bool visible) => new()
+ {
+ HeightRequest = 2,
+ BackgroundColor = Colors.Transparent,
+ VerticalOptions = LayoutOptions.Center,
+ Opacity = visible ? 1 : 0
+ };
+ }
+
+ private void OnStepPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName is nameof(StepperStep.Title) or nameof(StepperStep.Description)) Rebuild();
+ }
+
+ private void UpdateState()
+ {
+ var current = Current;
+ for (var i = 0; i < _markers.Count; i++)
+ {
+ var marker = _markers[i];
+ var active = i == current;
+ var completed = i < _highest && !active;
+
+ if (active) marker.Circle.Token(VisualElement.BackgroundColorProperty, ShellToken.Primary);
+ else
+ {
+ marker.Circle.ClearValue(VisualElement.BackgroundColorProperty);
+ marker.Circle.BackgroundColor = Colors.Transparent;
+ }
+ marker.Circle.Token(Border.StrokeProperty, active || completed ? ShellToken.Primary : ShellToken.Border);
+ marker.Number.IsVisible = !completed;
+ marker.Number.Token(Label.TextColorProperty, active ? ShellToken.PrimaryForeground : ShellToken.MutedForeground);
+ marker.Check.IsVisible = completed;
+ marker.Title.Token(Label.TextColorProperty, active || completed ? ShellToken.Foreground : ShellToken.MutedForeground);
+ marker.Before.Token(BoxView.ColorProperty, i <= _highest ? ShellToken.Primary : ShellToken.Border);
+ marker.After.Token(BoxView.ColorProperty, i < _highest ? ShellToken.Primary : ShellToken.Border);
+ }
+
+ _body.Content = _steps.Count > 0 ? _steps[current] : null;
+
+ _navigation.IsVisible = ShowNavigation && _steps.Count > 0;
+ _previous.Text = PreviousText;
+ _previous.IsEnabled = current > 0;
+ _next.Text = IsLast ? ConfirmText : NextText;
+ }
+}
+
+// One step of a Stepper: its Title and Description label the step, its content shows while the
+// step is active.
+public partial class StepperStep : ContentView
+{
+ public static readonly BindableProperty TitleProperty =
+ BindableProperty.Create(nameof(Title), typeof(string), typeof(StepperStep), string.Empty);
+
+ public static readonly BindableProperty DescriptionProperty =
+ BindableProperty.Create(nameof(Description), typeof(string), typeof(StepperStep), string.Empty);
+
+ public string Title
+ {
+ get => (string)GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public string Description
+ {
+ get => (string)GetValue(DescriptionProperty);
+ set => SetValue(DescriptionProperty, value);
+ }
+}
diff --git a/examples/MAUI.Demo/Components/UI/Table.cs b/examples/MAUI.Demo/Components/UI/Table.cs
new file mode 100644
index 0000000..cfee992
--- /dev/null
+++ b/examples/MAUI.Demo/Components/UI/Table.cs
@@ -0,0 +1,342 @@
+using Microsoft.Maui.Controls.Shapes;
+
+namespace MAUI.Demo.Components.UI;
+
+// Data table — text-sm rows inside a rounded-md border: a header row in the muted foreground,
+// rows divided by border-b that tint on hover, optional caption.
+//
+//
+// ...
+//
+//
+// ...
+//
+//
+// Columns takes Grid column widths. Use star and fixed widths: every row is its own grid, so an
+// Auto column would size per row. For long lists, use TableRow (with its own Columns) as the item
+// template of a CollectionView under a TableHeader.
+[ContentProperty(nameof(Rows))]
+public partial class Table : ContentView
+{
+ public static readonly BindableProperty ColumnsProperty =
+ BindableProperty.Create(nameof(Columns), typeof(string), typeof(Table), string.Empty,
+ propertyChanged: (b, o, n) => ((Table)b).Refresh());
+
+ public static readonly BindableProperty CaptionProperty =
+ BindableProperty.Create(nameof(Caption), typeof(string), typeof(Table), string.Empty,
+ propertyChanged: (b, o, n) => ((Table)b).UpdateChrome());
+
+ public static readonly BindableProperty BorderedProperty =
+ BindableProperty.Create(nameof(Bordered), typeof(bool), typeof(Table), true,
+ propertyChanged: (b, o, n) => ((Table)b).UpdateChrome());
+
+ // Narrower than this, the table scrolls horizontally instead of squeezing its columns.
+ public static readonly BindableProperty MinimumContentWidthProperty =
+ BindableProperty.Create(nameof(MinimumContentWidth), typeof(double), typeof(Table), 0d,
+ propertyChanged: (b, o, n) => ((Table)b).UpdateScrolling());
+
+ public string Columns
+ {
+ get => (string)GetValue(ColumnsProperty);
+ set => SetValue(ColumnsProperty, value);
+ }
+
+ public string Caption
+ {
+ get => (string)GetValue(CaptionProperty);
+ set => SetValue(CaptionProperty, value);
+ }
+
+ public bool Bordered
+ {
+ get => (bool)GetValue(BorderedProperty);
+ set => SetValue(BorderedProperty, value);
+ }
+
+ public double MinimumContentWidth
+ {
+ get => (double)GetValue(MinimumContentWidthProperty);
+ set => SetValue(MinimumContentWidthProperty, value);
+ }
+
+ public IList Rows => _rows.Children;
+
+ private readonly VerticalStackLayout _rows;
+ private readonly ScrollView _scroll;
+ private readonly Border _frame;
+ private readonly Label _caption;
+
+ public Table()
+ {
+ _rows = new VerticalStackLayout { Spacing = 0 };
+ _rows.ChildAdded += (_, _) => Refresh();
+ _rows.ChildRemoved += (_, _) => Refresh();
+ _scroll = new ScrollView { Orientation = ScrollOrientation.Horizontal };
+
+ _frame = new Border
+ {
+ Content = _rows,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ BackgroundColor = Colors.Transparent
+ };
+ _frame.Token(Border.StrokeProperty, ShellToken.Border);
+ _frame.SizeChanged += (_, _) => FitScrollContent();
+
+ _caption = new Label { FontSize = 14, HorizontalTextAlignment = TextAlignment.Center, Margin = new Thickness(0, 12, 0, 0) };
+ _caption.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+
+ Content = new VerticalStackLayout { Spacing = 0, Children = { _frame, _caption } };
+ Loaded += (_, _) => Refresh();
+ UpdateChrome();
+ }
+
+ // Parses "2*,*,100" into column widths. Empty means equal star columns.
+ internal static IReadOnlyList ParseColumns(string? columns)
+ {
+ if (string.IsNullOrWhiteSpace(columns)) return Array.Empty();
+ return columns.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(ParseWidth)
+ .ToList();
+
+ static GridLength ParseWidth(string part)
+ {
+ var invariant = System.Globalization.CultureInfo.InvariantCulture;
+ if (part.Equals("auto", StringComparison.OrdinalIgnoreCase)) return GridLength.Auto;
+ if (part.EndsWith('*'))
+ {
+ var factor = part[..^1];
+ return new GridLength(factor.Length > 0 && double.TryParse(factor, System.Globalization.NumberStyles.Float, invariant, out var stars) ? stars : 1, GridUnitType.Star);
+ }
+ return double.TryParse(part, System.Globalization.NumberStyles.Float, invariant, out var width) ? new GridLength(width) : GridLength.Star;
+ }
+ }
+
+ // The table hands rows their columns and tells the last one to drop its divider.
+ private void Refresh()
+ {
+ var widths = ParseColumns(Columns);
+ var rows = _rows.Children.OfType().ToList();
+ for (var i = 0; i < rows.Count; i++)
+ {
+ if (string.IsNullOrWhiteSpace(rows[i].Columns)) rows[i].Apply(widths);
+ rows[i].ShowDivider = i < rows.Count - 1;
+ }
+ }
+
+ private void UpdateChrome()
+ {
+ _frame.StrokeThickness = Bordered ? 1 : 0;
+ _caption.Text = Caption ?? string.Empty;
+ _caption.IsVisible = !string.IsNullOrEmpty(Caption);
+ }
+
+ private void UpdateScrolling()
+ {
+ var scrolls = MinimumContentWidth > 0;
+ if (scrolls && _frame.Content != _scroll)
+ {
+ _frame.Content = null;
+ _scroll.Content = _rows;
+ _frame.Content = _scroll;
+ }
+ else if (!scrolls && _frame.Content != _rows)
+ {
+ _scroll.Content = null;
+ _frame.Content = _rows;
+ _rows.WidthRequest = -1;
+ }
+ FitScrollContent();
+ }
+
+ // Inside a horizontal ScrollView rows measure at their minimum width; give them a real one.
+ private void FitScrollContent()
+ {
+ if (MinimumContentWidth <= 0 || _frame.Width <= 0) return;
+ _rows.WidthRequest = Math.Max(_frame.Width - 2 * _frame.StrokeThickness, MinimumContentWidth);
+ }
+}
+
+// Body row — border-b, hover:bg-muted/50, bg-muted when selected. Its children are the cells,
+// placed in columns in order.
+public partial class TableRow : Grid
+{
+ // Set only for rows used outside a Table (e.g. a CollectionView item template).
+ public static readonly BindableProperty ColumnsProperty =
+ BindableProperty.Create(nameof(Columns), typeof(string), typeof(TableRow), string.Empty,
+ propertyChanged: (b, o, n) => ((TableRow)b).Apply(Table.ParseColumns((string)n)));
+
+ public static readonly BindableProperty IsSelectedProperty =
+ BindableProperty.Create(nameof(IsSelected), typeof(bool), typeof(TableRow), false,
+ propertyChanged: (b, o, n) => ((TableRow)b).UpdateFill());
+
+ public string Columns
+ {
+ get => (string)GetValue(ColumnsProperty);
+ set => SetValue(ColumnsProperty, value);
+ }
+
+ public bool IsSelected
+ {
+ get => (bool)GetValue(IsSelectedProperty);
+ set => SetValue(IsSelectedProperty, value);
+ }
+
+ public event EventHandler? Tapped;
+
+ private readonly BoxView _fill;
+ private readonly BoxView _divider;
+ private IReadOnlyList _widths = Array.Empty();
+ private bool _hovered;
+
+ internal bool ShowDivider
+ {
+ get => _divider.IsVisible;
+ set => _divider.IsVisible = value;
+ }
+
+ protected virtual bool Interactive => true;
+
+ public TableRow()
+ {
+ _fill = new BoxView { BackgroundColor = Colors.Transparent, Opacity = 0, InputTransparent = true, ZIndex = -1 };
+ _fill.Token(BoxView.ColorProperty, ShellToken.Muted);
+ _divider = new BoxView { BackgroundColor = Colors.Transparent, HeightRequest = 1, VerticalOptions = LayoutOptions.End, InputTransparent = true };
+ _divider.Token(BoxView.ColorProperty, ShellToken.Border);
+ Children.Add(_fill);
+ Children.Add(_divider);
+ // A layout with no background only takes pointer input over its children.
+ BackgroundColor = Colors.Transparent;
+
+ if (Interactive)
+ {
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => { _hovered = true; UpdateFill(); };
+ pointer.PointerExited += (_, _) => { _hovered = false; UpdateFill(); };
+ GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => Tapped?.Invoke(this, EventArgs.Empty);
+ GestureRecognizers.Add(tap);
+ }
+ }
+
+ protected override void OnChildAdded(Element child)
+ {
+ base.OnChildAdded(child);
+ // _divider is still null while the constructor adds the row's own chrome.
+ if (_divider != null && child != _fill && child != _divider) Apply(_widths);
+ }
+
+ internal void Apply(IReadOnlyList widths)
+ {
+ _widths = widths;
+ var cells = Children.Where(c => c != _fill && c != _divider).ToList();
+ var count = Math.Max(1, Math.Max(cells.Count, widths.Count));
+
+ ColumnDefinitions.Clear();
+ for (var i = 0; i < count; i++)
+ ColumnDefinitions.Add(new ColumnDefinition(i < widths.Count ? widths[i] : GridLength.Star));
+ for (var i = 0; i < cells.Count; i++)
+ Grid.SetColumn((BindableObject)cells[i], i);
+ Grid.SetColumnSpan(_fill, count);
+ Grid.SetColumnSpan(_divider, count);
+ }
+
+ private void UpdateFill() => _fill.Opacity = IsSelected ? 1 : _hovered ? 0.5 : 0;
+}
+
+// Header row: holds TableHead cells, no hover.
+public partial class TableHeader : TableRow
+{
+ protected override bool Interactive => false;
+}
+
+// Header cell — h-11 px-4 font-medium text-muted-foreground.
+public partial class TableHead : ContentView
+{
+ public static readonly BindableProperty TextProperty =
+ BindableProperty.Create(nameof(Text), typeof(string), typeof(TableHead), string.Empty,
+ propertyChanged: (b, o, n) => ((TableHead)b)._label.Text = n as string ?? string.Empty);
+
+ public static readonly BindableProperty HorizontalTextAlignmentProperty =
+ BindableProperty.Create(nameof(HorizontalTextAlignment), typeof(TextAlignment), typeof(TableHead), TextAlignment.Start,
+ propertyChanged: (b, o, n) => ((TableHead)b)._label.HorizontalTextAlignment = (TextAlignment)n);
+
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public TextAlignment HorizontalTextAlignment
+ {
+ get => (TextAlignment)GetValue(HorizontalTextAlignmentProperty);
+ set => SetValue(HorizontalTextAlignmentProperty, value);
+ }
+
+ private readonly Label _label;
+
+ public TableHead()
+ {
+ _label = new Label
+ {
+ FontSize = 14,
+ FontAttributes = FontAttributes.Bold,
+ VerticalTextAlignment = TextAlignment.Center,
+ LineBreakMode = LineBreakMode.TailTruncation
+ };
+ _label.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+ HeightRequest = 44;
+ Padding = new Thickness(16, 0);
+ Content = _label;
+ }
+}
+
+// Body cell — p-4 text-sm. Set Text, or put any view inside.
+public partial class TableCell : ContentView
+{
+ public static readonly BindableProperty TextProperty =
+ BindableProperty.Create(nameof(Text), typeof(string), typeof(TableCell), string.Empty,
+ propertyChanged: (b, o, n) => ((TableCell)b)._label.Text = n as string ?? string.Empty);
+
+ public static readonly BindableProperty HorizontalTextAlignmentProperty =
+ BindableProperty.Create(nameof(HorizontalTextAlignment), typeof(TextAlignment), typeof(TableCell), TextAlignment.Start,
+ propertyChanged: (b, o, n) => ((TableCell)b)._label.HorizontalTextAlignment = (TextAlignment)n);
+
+ public static readonly BindableProperty IsBoldProperty =
+ BindableProperty.Create(nameof(IsBold), typeof(bool), typeof(TableCell), false,
+ propertyChanged: (b, o, n) => ((TableCell)b)._label.FontAttributes = (bool)n ? FontAttributes.Bold : FontAttributes.None);
+
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public TextAlignment HorizontalTextAlignment
+ {
+ get => (TextAlignment)GetValue(HorizontalTextAlignmentProperty);
+ set => SetValue(HorizontalTextAlignmentProperty, value);
+ }
+
+ public bool IsBold
+ {
+ get => (bool)GetValue(IsBoldProperty);
+ set => SetValue(IsBoldProperty, value);
+ }
+
+ private readonly Label _label;
+
+ public TableCell()
+ {
+ _label = new Label
+ {
+ FontSize = 14,
+ VerticalTextAlignment = TextAlignment.Center,
+ VerticalOptions = LayoutOptions.Center,
+ LineBreakMode = LineBreakMode.TailTruncation
+ };
+ _label.Token(Label.TextColorProperty, ShellToken.Foreground);
+ Padding = new Thickness(16, 12);
+ Content = _label;
+ }
+}
diff --git a/examples/MAUI.Demo/Components/UI/TagInput.cs b/examples/MAUI.Demo/Components/UI/TagInput.cs
new file mode 100644
index 0000000..dc69d10
--- /dev/null
+++ b/examples/MAUI.Demo/Components/UI/TagInput.cs
@@ -0,0 +1,218 @@
+using System.Collections.Specialized;
+using Microsoft.Maui.Controls.Shapes;
+
+namespace MAUI.Demo.Components.UI;
+
+// Tag field — min-h-10 rounded-md border holding chips (rounded-md bg-secondary text-xs) and a
+// text field that wraps with them. Enter, a comma or a semicolon turns what was typed into a
+// tag; the × on a chip removes it.
+// Usage:
+public partial class TagInput : ContentView
+{
+ public static readonly BindableProperty TagsProperty =
+ BindableProperty.Create(nameof(Tags), typeof(IList), typeof(TagInput), null, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((TagInput)b).OnTagsReplaced(o, n));
+
+ public static readonly BindableProperty PlaceholderProperty =
+ BindableProperty.Create(nameof(Placeholder), typeof(string), typeof(TagInput), "Add tag...",
+ propertyChanged: (b, o, n) => ((TagInput)b).UpdatePlaceholder());
+
+ // 0 means no limit.
+ public static readonly BindableProperty MaxTagsProperty =
+ BindableProperty.Create(nameof(MaxTags), typeof(int), typeof(TagInput), 0);
+
+ public static readonly BindableProperty AllowDuplicatesProperty =
+ BindableProperty.Create(nameof(AllowDuplicates), typeof(bool), typeof(TagInput), false);
+
+ public IList? Tags
+ {
+ get => (IList?)GetValue(TagsProperty);
+ set => SetValue(TagsProperty, value);
+ }
+
+ public string Placeholder
+ {
+ get => (string)GetValue(PlaceholderProperty);
+ set => SetValue(PlaceholderProperty, value);
+ }
+
+ public int MaxTags
+ {
+ get => (int)GetValue(MaxTagsProperty);
+ set => SetValue(MaxTagsProperty, value);
+ }
+
+ public bool AllowDuplicates
+ {
+ get => (bool)GetValue(AllowDuplicatesProperty);
+ set => SetValue(AllowDuplicatesProperty, value);
+ }
+
+ public event EventHandler>? TagsChanged;
+
+ private static readonly char[] Separators = { ',', ';', '\n', '\t' };
+
+ private readonly Border _frame;
+ private readonly WrapLayout _flow;
+ private readonly Entry _entry;
+
+ public TagInput()
+ {
+ _entry = new Entry
+ {
+ FontSize = 14,
+ BackgroundColor = Colors.Transparent,
+ HeightRequest = 28,
+ MinimumHeightRequest = 28, // Android gives text fields a 44dp minimum otherwise
+ Margin = new Thickness(2, 2),
+ ClearButtonVisibility = ClearButtonVisibility.Never,
+ IsTextPredictionEnabled = false
+ };
+ _entry.Token(Entry.TextColorProperty, ShellToken.Foreground);
+ _entry.Token(Entry.PlaceholderColorProperty, ShellToken.MutedForeground);
+ ShellPlatform.StripNativeChrome(_entry);
+ ShellFocus.Track(_entry);
+ _entry.Completed += (_, _) =>
+ {
+ AddFromEntry();
+ KeepTyping();
+ };
+ _entry.TextChanged += (_, e) =>
+ {
+ // Typing or pasting a separator commits everything before it.
+ if (e.NewTextValue?.IndexOfAny(Separators) >= 0) AddFromEntry();
+ };
+ _entry.Focused += (_, _) => _frame!.Token(Border.StrokeProperty, ShellToken.Ring);
+ _entry.Unfocused += (_, _) => _frame!.Token(Border.StrokeProperty, ShellToken.Input);
+
+ _flow = new WrapLayout { Spacing = 0, LineSpacing = 0, LastChildFill = true, Children = { _entry } };
+
+ _frame = new Border
+ {
+ Content = _flow,
+ MinimumHeightRequest = 40,
+ Padding = new Thickness(8, 3),
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ BackgroundColor = Colors.Transparent
+ };
+ _frame.Token(Border.StrokeProperty, ShellToken.Input);
+ // Tapping the empty part of the field focuses the text entry.
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { if (IsEnabled) _entry.Focus(); };
+ _frame.GestureRecognizers.Add(tap);
+
+ Content = _frame;
+ UpdatePlaceholder();
+ }
+
+ private IReadOnlyList Current => (IReadOnlyList?)Tags?.ToList() ?? Array.Empty();
+
+ public void Add(string tag)
+ {
+ var text = tag.Trim();
+ if (text.Length == 0) return;
+ var current = Current;
+ if (MaxTags > 0 && current.Count >= MaxTags) return;
+ if (!AllowDuplicates && current.Contains(text, StringComparer.OrdinalIgnoreCase)) return;
+ // A new list each time, so a two-way binding sees the change.
+ Tags = new List(current) { text };
+ }
+
+ public void Remove(string tag)
+ {
+ var next = new List(Current);
+ if (next.Remove(tag)) Tags = next;
+ }
+
+ // Enter dismisses the soft keyboard; stay in the field so tags can be entered in a row.
+ private void KeepTyping()
+ {
+ Dispatcher.Dispatch(async () =>
+ {
+ try
+ {
+ _entry.Focus();
+ await _entry.ShowSoftInputAsync(CancellationToken.None);
+ }
+ catch (Exception)
+ {
+ // No soft keyboard here, or the field is gone.
+ }
+ });
+ }
+
+ private void AddFromEntry()
+ {
+ var text = _entry.Text ?? string.Empty;
+ _entry.Text = string.Empty;
+ foreach (var part in text.Split(Separators, StringSplitOptions.RemoveEmptyEntries))
+ Add(part);
+ }
+
+ private void OnTagsReplaced(object? oldValue, object? newValue)
+ {
+ if (oldValue is INotifyCollectionChanged before) before.CollectionChanged -= OnTagsMutated;
+ if (newValue is INotifyCollectionChanged after) after.CollectionChanged += OnTagsMutated;
+ OnTagsMutated(this, null);
+ }
+
+ private void OnTagsMutated(object? sender, NotifyCollectionChangedEventArgs? e)
+ {
+ Rebuild();
+ TagsChanged?.Invoke(this, Current);
+ }
+
+ private void Rebuild()
+ {
+ foreach (var chip in _flow.Children.Where(c => c != _entry).ToList())
+ _flow.Children.Remove(chip);
+ var index = 0;
+ foreach (var tag in Current)
+ _flow.Children.Insert(index++, CreateChip(tag));
+ UpdatePlaceholder();
+ }
+
+ private View CreateChip(string tag)
+ {
+ var label = new Label { Text = tag, FontSize = 12, FontAttributes = FontAttributes.Bold, VerticalTextAlignment = TextAlignment.Center, LineBreakMode = LineBreakMode.TailTruncation };
+ label.Token(Label.TextColorProperty, ShellToken.SecondaryForeground);
+ var close = new Icon { Name = IconName.X, Size = 12, Token = ShellToken.MutedForeground };
+ // 12px icon in a 20px tap target.
+ var remove = new Grid { WidthRequest = 20, HeightRequest = 20, BackgroundColor = Colors.Transparent, Children = { close } };
+ close.HorizontalOptions = LayoutOptions.Center;
+ close.VerticalOptions = LayoutOptions.Center;
+ SemanticProperties.SetDescription(remove, $"Remove {tag}");
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => close.Token = ShellToken.Destructive;
+ pointer.PointerExited += (_, _) => close.Token = ShellToken.MutedForeground;
+ remove.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { if (IsEnabled) Remove(tag); };
+ remove.GestureRecognizers.Add(tap);
+
+ var chip = new Border
+ {
+ Content = new HorizontalStackLayout { Spacing = 2, Children = { label, remove } },
+ Padding = new Thickness(8, 0, 2, 0),
+ HeightRequest = 24,
+ Margin = new Thickness(2, 3),
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }
+ };
+ chip.Token(VisualElement.BackgroundColorProperty, ShellToken.Secondary);
+ return chip;
+ }
+
+ private void UpdatePlaceholder() => _entry.Placeholder = Current.Count == 0 ? Placeholder : string.Empty;
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == IsEnabledProperty.PropertyName && _entry != null)
+ {
+ Opacity = IsEnabled ? 1.0 : 0.5;
+ _entry.IsEnabled = IsEnabled;
+ }
+ }
+}
diff --git a/examples/MAUI.Demo/Components/UI/Timeline.cs b/examples/MAUI.Demo/Components/UI/Timeline.cs
new file mode 100644
index 0000000..b0bc6bc
--- /dev/null
+++ b/examples/MAUI.Demo/Components/UI/Timeline.cs
@@ -0,0 +1,220 @@
+using Microsoft.Maui.Controls.Shapes;
+
+namespace MAUI.Demo.Components.UI;
+
+// Vertical timeline — markers joined by a hairline, each with a title, a time and details.
+//
+//
+//
+//
+//
+//
+[ContentProperty(nameof(Items))]
+public partial class Timeline : ContentView
+{
+ private readonly VerticalStackLayout _stack;
+
+ public IList Items => _stack.Children;
+
+ public Timeline()
+ {
+ _stack = new VerticalStackLayout { Spacing = 0 };
+ _stack.ChildAdded += (_, _) => Refresh();
+ _stack.ChildRemoved += (_, _) => Refresh();
+ Content = _stack;
+ Loaded += (_, _) => Refresh();
+ }
+
+ // The timeline tells each item if it is first or last, so the line starts and stops at a marker.
+ private void Refresh()
+ {
+ var items = _stack.Children.OfType().ToList();
+ for (var i = 0; i < items.Count; i++)
+ items[i].Place(isFirst: i == 0, isLast: i == items.Count - 1);
+ }
+}
+
+// One entry of a Timeline. Without an Icon the marker is a dot (ringed when active); with one it
+// is a 28px circle around the icon.
+[ContentProperty(nameof(Body))]
+public partial class TimelineItem : ContentView
+{
+ public static readonly BindableProperty TitleProperty =
+ BindableProperty.Create(nameof(Title), typeof(string), typeof(TimelineItem), string.Empty,
+ propertyChanged: (b, o, n) => ((TimelineItem)b).Update());
+
+ public static readonly BindableProperty TimeProperty =
+ BindableProperty.Create(nameof(Time), typeof(string), typeof(TimelineItem), string.Empty,
+ propertyChanged: (b, o, n) => ((TimelineItem)b).Update());
+
+ public static readonly BindableProperty TextProperty =
+ BindableProperty.Create(nameof(Text), typeof(string), typeof(TimelineItem), string.Empty,
+ propertyChanged: (b, o, n) => ((TimelineItem)b).Update());
+
+ public static readonly BindableProperty IsActiveProperty =
+ BindableProperty.Create(nameof(IsActive), typeof(bool), typeof(TimelineItem), false,
+ propertyChanged: (b, o, n) => ((TimelineItem)b).Update());
+
+ public static readonly BindableProperty IconProperty =
+ BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(TimelineItem), IconName.None,
+ propertyChanged: (b, o, n) => ((TimelineItem)b).Update());
+
+ public static readonly BindableProperty BodyProperty =
+ BindableProperty.Create(nameof(Body), typeof(View), typeof(TimelineItem), null,
+ propertyChanged: (b, o, n) => ((TimelineItem)b).Update());
+
+ public string Title
+ {
+ get => (string)GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public string Time
+ {
+ get => (string)GetValue(TimeProperty);
+ set => SetValue(TimeProperty, value);
+ }
+
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public bool IsActive
+ {
+ get => (bool)GetValue(IsActiveProperty);
+ set => SetValue(IsActiveProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => (IconName)GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public View? Body
+ {
+ get => (View?)GetValue(BodyProperty);
+ set => SetValue(BodyProperty, value);
+ }
+
+ private const double MarkerSize = 28;
+
+ private readonly BoxView _line;
+ private readonly BoxView _ring;
+ private readonly Border _dot;
+ private readonly Border _badge;
+ private readonly Icon _icon;
+ private readonly Label _title;
+ private readonly Label _time;
+ private readonly Label _text;
+ private readonly ContentView _body;
+ private readonly VerticalStackLayout _details;
+
+ public TimelineItem()
+ {
+ _line = new BoxView { WidthRequest = 1, BackgroundColor = Colors.Transparent, HorizontalOptions = LayoutOptions.Center };
+ _line.Token(BoxView.ColorProperty, ShellToken.Border);
+
+ // ring-4 ring-primary/20 around the active dot.
+ _ring = new BoxView { WidthRequest = 18, HeightRequest = 18, CornerRadius = 9, BackgroundColor = Colors.Transparent, Opacity = 0.2, HorizontalOptions = LayoutOptions.Center, VerticalOptions = LayoutOptions.Center };
+ _ring.Token(BoxView.ColorProperty, ShellToken.Primary);
+ _dot = new Border
+ {
+ WidthRequest = 10,
+ HeightRequest = 10,
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = 5 },
+ HorizontalOptions = LayoutOptions.Center,
+ VerticalOptions = LayoutOptions.Center
+ };
+ _icon = new Icon { Size = 14 };
+ _badge = new Border
+ {
+ Content = _icon,
+ WidthRequest = MarkerSize,
+ HeightRequest = MarkerSize,
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = MarkerSize / 2 }
+ };
+ _badge.Token(VisualElement.BackgroundColorProperty, ShellToken.Background);
+ var marker = new Grid
+ {
+ WidthRequest = MarkerSize,
+ HeightRequest = MarkerSize,
+ VerticalOptions = LayoutOptions.Start,
+ Children = { _ring, _dot, _badge }
+ };
+
+ _title = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold, VerticalTextAlignment = TextAlignment.Center };
+ _title.Token(Label.TextColorProperty, ShellToken.Foreground);
+ _time = new Label { FontSize = 12, VerticalTextAlignment = TextAlignment.Center };
+ _time.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+ _text = new Label { FontSize = 14 };
+ _text.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+ _body = new ContentView();
+ _details = new VerticalStackLayout
+ {
+ Spacing = 2,
+ Children =
+ {
+ new HorizontalStackLayout { Spacing = 8, MinimumHeightRequest = MarkerSize, Children = { _title, _time } },
+ _text,
+ _body
+ }
+ };
+
+ var grid = new Grid
+ {
+ ColumnSpacing = 12,
+ ColumnDefinitions = { new ColumnDefinition(MarkerSize), new ColumnDefinition(GridLength.Star) }
+ };
+ grid.Add(_line, 0, 0);
+ grid.Add(marker, 0, 0);
+ grid.Add(_details, 1, 0);
+ Content = grid;
+ Place(isFirst: true, isLast: true);
+ Update();
+ }
+
+ internal void Place(bool isFirst, bool isLast)
+ {
+ // The line runs through the marker's center: from it down (first), up to it (last), or
+ // the full height (in between). A lone item has no line.
+ _line.IsVisible = !(isFirst && isLast);
+ if (isLast)
+ {
+ _line.VerticalOptions = LayoutOptions.Start;
+ _line.HeightRequest = MarkerSize / 2;
+ _line.Margin = new Thickness(0);
+ }
+ else
+ {
+ _line.VerticalOptions = LayoutOptions.Fill;
+ _line.HeightRequest = -1;
+ _line.Margin = new Thickness(0, isFirst ? MarkerSize / 2 : 0, 0, 0);
+ }
+ _details.Padding = new Thickness(0, 0, 0, isLast ? 0 : 24);
+ }
+
+ private void Update()
+ {
+ var hasIcon = Icon != IconName.None;
+ _badge.IsVisible = hasIcon;
+ _dot.IsVisible = !hasIcon;
+ _ring.IsVisible = !hasIcon && IsActive;
+ _icon.Name = Icon;
+ _icon.Token = IsActive ? ShellToken.Primary : ShellToken.MutedForeground;
+ _badge.Token(Border.StrokeProperty, IsActive ? ShellToken.Primary : ShellToken.Border);
+ _dot.Token(VisualElement.BackgroundColorProperty, IsActive ? ShellToken.Primary : ShellToken.MutedForeground);
+
+ _title.Text = Title ?? string.Empty;
+ _time.Text = Time ?? string.Empty;
+ _time.IsVisible = !string.IsNullOrEmpty(Time);
+ _text.Text = Text ?? string.Empty;
+ _text.IsVisible = !string.IsNullOrEmpty(Text);
+ _body.Content = Body;
+ _body.IsVisible = Body != null;
+ }
+}
diff --git a/examples/MAUI.Demo/Components/UI/Toast.cs b/examples/MAUI.Demo/Components/UI/Toast.cs
index c729702..aaffb63 100644
--- a/examples/MAUI.Demo/Components/UI/Toast.cs
+++ b/examples/MAUI.Demo/Components/UI/Toast.cs
@@ -112,6 +112,8 @@ public Toaster()
// Places the toast stack in the page layer (of this Toaster's page, or the page on screen).
internal bool Attach()
{
+ // The stack keeps itself clear of the system bars with its margin (see Fit).
+ ShellPortal.EdgeToEdge(_stack);
if (!ShellPortal.Attach(this, _stack)) return false;
if (_stack.Parent is Grid layer)
{
diff --git a/examples/MAUI.Demo/Components/UI/ToggleGroup.cs b/examples/MAUI.Demo/Components/UI/ToggleGroup.cs
new file mode 100644
index 0000000..9ea0201
--- /dev/null
+++ b/examples/MAUI.Demo/Components/UI/ToggleGroup.cs
@@ -0,0 +1,244 @@
+using System.Collections.Specialized;
+using Microsoft.Maui.Controls.Shapes;
+
+namespace MAUI.Demo.Components.UI;
+
+// A row of toggles that work as one control: pick one (default) or several (Multiple).
+//
+//
+//
+//
+// ...
+[ContentProperty(nameof(Items))]
+public partial class ToggleGroup : ContentView
+{
+ public static readonly BindableProperty MultipleProperty =
+ BindableProperty.Create(nameof(Multiple), typeof(bool), typeof(ToggleGroup), false,
+ propertyChanged: (b, o, n) => ((ToggleGroup)b).Refresh());
+
+ // The pressed item's Value in single mode; null when nothing is pressed.
+ public static readonly BindableProperty ValueProperty =
+ BindableProperty.Create(nameof(Value), typeof(string), typeof(ToggleGroup), null, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((ToggleGroup)b).OnValueChanged());
+
+ // The pressed items' Values in Multiple mode.
+ public static readonly BindableProperty ValuesProperty =
+ BindableProperty.Create(nameof(Values), typeof(IList), typeof(ToggleGroup), null, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((ToggleGroup)b).OnValuesReplaced(o, n));
+
+ public static readonly BindableProperty VariantProperty =
+ BindableProperty.Create(nameof(Variant), typeof(ToggleGroupVariant), typeof(ToggleGroup), ToggleGroupVariant.Default,
+ propertyChanged: (b, o, n) => ((ToggleGroup)b).Refresh());
+
+ public bool Multiple
+ {
+ get => (bool)GetValue(MultipleProperty);
+ set => SetValue(MultipleProperty, value);
+ }
+
+ public string? Value
+ {
+ get => (string?)GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public IList? Values
+ {
+ get => (IList?)GetValue(ValuesProperty);
+ set => SetValue(ValuesProperty, value);
+ }
+
+ public ToggleGroupVariant Variant
+ {
+ get => (ToggleGroupVariant)GetValue(VariantProperty);
+ set => SetValue(VariantProperty, value);
+ }
+
+ public IList Items => _row.Children;
+
+ public event EventHandler? ValueChanged;
+ public event EventHandler>? ValuesChanged;
+
+ private readonly HorizontalStackLayout _row;
+
+ public ToggleGroup()
+ {
+ _row = new HorizontalStackLayout { Spacing = 4 };
+ _row.ChildAdded += (_, _) => Refresh();
+ Content = _row;
+ HorizontalOptions = LayoutOptions.Start;
+ Loaded += (_, _) => Refresh();
+ }
+
+ public bool IsPressed(string value) =>
+ Multiple ? Values?.Contains(value) == true : Value == value;
+
+ internal void Toggle(string value)
+ {
+ if (!IsEnabled) return;
+ if (Multiple)
+ {
+ // A new list each time, so a two-way binding sees the change.
+ var next = new List(Values ?? Array.Empty());
+ if (!next.Remove(value)) next.Add(value);
+ Values = next;
+ }
+ else
+ {
+ Value = Value == value ? null : value;
+ }
+ }
+
+ private void OnValueChanged()
+ {
+ Refresh();
+ ValueChanged?.Invoke(this, Value);
+ }
+
+ private void OnValuesReplaced(object? oldValue, object? newValue)
+ {
+ if (oldValue is INotifyCollectionChanged before) before.CollectionChanged -= OnValuesMutated;
+ if (newValue is INotifyCollectionChanged after) after.CollectionChanged += OnValuesMutated;
+ OnValuesMutated(this, null);
+ }
+
+ private void OnValuesMutated(object? sender, NotifyCollectionChangedEventArgs? e)
+ {
+ Refresh();
+ ValuesChanged?.Invoke(this, (Values ?? Array.Empty()).ToList());
+ }
+
+ // Items don't track the group's state: the group pushes it to them.
+ private void Refresh()
+ {
+ foreach (var item in _row.Children.OfType())
+ item.Apply(IsPressed(item.Value), Variant == ToggleGroupVariant.Outline);
+ }
+}
+
+// One toggle in a ToggleGroup — h-9 min-w-9 rounded-md px-2.5, bg-accent while pressed.
+public partial class ToggleGroupItem : ContentView
+{
+ public static readonly BindableProperty ValueProperty =
+ BindableProperty.Create(nameof(Value), typeof(string), typeof(ToggleGroupItem), string.Empty);
+
+ public static readonly BindableProperty TextProperty =
+ BindableProperty.Create(nameof(Text), typeof(string), typeof(ToggleGroupItem), string.Empty,
+ propertyChanged: (b, o, n) => ((ToggleGroupItem)b).Update());
+
+ public static readonly BindableProperty IconProperty =
+ BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(ToggleGroupItem), IconName.None,
+ propertyChanged: (b, o, n) => ((ToggleGroupItem)b).Update());
+
+ public string Value
+ {
+ get => (string)GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => (IconName)GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ private readonly Border _border;
+ private readonly Icon _icon;
+ private readonly Label _label;
+ private bool _pressed;
+ private bool _outline;
+ private bool _hovered;
+
+ public ToggleGroupItem()
+ {
+ _icon = new Icon { Size = 16, IsVisible = false };
+ _label = new Label
+ {
+ FontSize = 14,
+ FontAttributes = FontAttributes.Bold,
+ VerticalOptions = LayoutOptions.Center,
+ VerticalTextAlignment = TextAlignment.Center,
+ LineBreakMode = LineBreakMode.NoWrap
+ };
+ _border = new Border
+ {
+ Content = new HorizontalStackLayout
+ {
+ Spacing = 8,
+ HorizontalOptions = LayoutOptions.Center,
+ VerticalOptions = LayoutOptions.Center,
+ Children = { _icon, _label }
+ },
+ HeightRequest = 36,
+ MinimumWidthRequest = 36,
+ Padding = new Thickness(10, 0),
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }
+ };
+
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => { _hovered = true; Update(); };
+ pointer.PointerExited += (_, _) => { _hovered = false; Update(); };
+ _border.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { ShellFocus.FocusPressed(this); Press(); };
+ _border.GestureRecognizers.Add(tap);
+ ShellFocus.MakeFocusable(this, Press);
+
+ Content = _border;
+ Update();
+ }
+
+ private void Press()
+ {
+ if (IsEnabled) this.FindParentOfType()?.Toggle(Value);
+ }
+
+ internal void Apply(bool pressed, bool outline)
+ {
+ _pressed = pressed;
+ _outline = outline;
+ Update();
+ }
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == IsEnabledProperty.PropertyName)
+ Opacity = IsEnabled ? 1.0 : 0.5;
+ }
+
+ private void Update()
+ {
+ _label.Text = Text ?? string.Empty;
+ _label.IsVisible = !string.IsNullOrEmpty(Text);
+ _icon.Name = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+
+ _border.StrokeThickness = _outline ? 1 : 0;
+ if (_outline) _border.Token(Border.StrokeProperty, ShellToken.Input);
+
+ var foreground = _pressed ? ShellToken.AccentForeground
+ : _hovered ? ShellToken.MutedForeground
+ : ShellToken.Foreground;
+ _label.Token(Label.TextColorProperty, foreground);
+ _icon.Token = foreground;
+
+ if (_pressed)
+ _border.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent);
+ else if (_hovered && IsEnabled)
+ _border.Token(VisualElement.BackgroundColorProperty, ShellToken.Muted);
+ else
+ {
+ _border.ClearValue(VisualElement.BackgroundColorProperty);
+ _border.BackgroundColor = Colors.Transparent;
+ }
+ }
+}
+
+public enum ToggleGroupVariant { Default, Outline }
diff --git a/examples/MAUI.Demo/Components/UI/TreeView.cs b/examples/MAUI.Demo/Components/UI/TreeView.cs
new file mode 100644
index 0000000..dd31389
--- /dev/null
+++ b/examples/MAUI.Demo/Components/UI/TreeView.cs
@@ -0,0 +1,218 @@
+using Microsoft.Maui.Controls.Shapes;
+
+namespace MAUI.Demo.Components.UI;
+
+// Tree of expandable rows — rounded-md px-2 py-1.5 text-sm rows, hover:bg-muted, bg-accent when
+// selected; children are indented behind a hairline. Tapping a row selects it and, when it has
+// children, expands or collapses it.
+//
+//
+//
+//
+//
+[ContentProperty(nameof(Items))]
+public partial class TreeView : ContentView
+{
+ // The selected item's Value (its Text when no Value is set).
+ public static readonly BindableProperty SelectedValueProperty =
+ BindableProperty.Create(nameof(SelectedValue), typeof(string), typeof(TreeView), null, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((TreeView)b).OnSelectedValueChanged());
+
+ public string? SelectedValue
+ {
+ get => (string?)GetValue(SelectedValueProperty);
+ set => SetValue(SelectedValueProperty, value);
+ }
+
+ public IList Items => _stack.Children;
+
+ public event EventHandler? SelectedValueChanged;
+
+ private readonly VerticalStackLayout _stack;
+
+ public TreeView()
+ {
+ _stack = new VerticalStackLayout { Spacing = 2 };
+ Content = _stack;
+ Loaded += (_, _) => Refresh();
+ }
+
+ private void OnSelectedValueChanged()
+ {
+ Refresh();
+ SelectedValueChanged?.Invoke(this, SelectedValue);
+ }
+
+ // Items don't track the selection: the tree pushes it to every item under it.
+ private void Refresh()
+ {
+ foreach (var item in this.FindDescendantsOfType())
+ item.ApplySelection(SelectedValue);
+ }
+}
+
+// One row of a TreeView. Nested TreeViewItems become its children.
+[ContentProperty(nameof(Items))]
+public partial class TreeViewItem : ContentView
+{
+ public static readonly BindableProperty TextProperty =
+ BindableProperty.Create(nameof(Text), typeof(string), typeof(TreeViewItem), string.Empty,
+ propertyChanged: (b, o, n) => ((TreeViewItem)b).Update());
+
+ // Identifies the item for selection. Defaults to Text.
+ public static readonly BindableProperty ValueProperty =
+ BindableProperty.Create(nameof(Value), typeof(string), typeof(TreeViewItem), null);
+
+ public static readonly BindableProperty IconProperty =
+ BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(TreeViewItem), IconName.None,
+ propertyChanged: (b, o, n) => ((TreeViewItem)b).Update());
+
+ public static readonly BindableProperty IsExpandedProperty =
+ BindableProperty.Create(nameof(IsExpanded), typeof(bool), typeof(TreeViewItem), false, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((TreeViewItem)b).OnExpandedChanged());
+
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public string? Value
+ {
+ get => (string?)GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => (IconName)GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public bool IsExpanded
+ {
+ get => (bool)GetValue(IsExpandedProperty);
+ set => SetValue(IsExpandedProperty, value);
+ }
+
+ public IList Items => _children.Children;
+
+ public string EffectiveValue => Value ?? Text ?? string.Empty;
+
+ private readonly Border _row;
+ private readonly Icon _chevron;
+ private readonly Icon _icon;
+ private readonly Label _label;
+ private readonly Grid _childrenHost;
+ private readonly VerticalStackLayout _children;
+ private bool _selected;
+ private bool _hovered;
+
+ public TreeViewItem()
+ {
+ _chevron = new Icon { Name = IconName.ChevronRight, Size = 16, Token = ShellToken.MutedForeground };
+ _icon = new Icon { Size = 16, Token = ShellToken.MutedForeground, IsVisible = false };
+ _label = new Label
+ {
+ FontSize = 14,
+ VerticalTextAlignment = TextAlignment.Center,
+ VerticalOptions = LayoutOptions.Center,
+ LineBreakMode = LineBreakMode.TailTruncation
+ };
+
+ // The chevron keeps its 16px slot on leaves, so labels line up within a level.
+ var slot = new Grid { WidthRequest = 16, HeightRequest = 16, VerticalOptions = LayoutOptions.Center, Children = { _chevron } };
+ var content = new Grid
+ {
+ ColumnSpacing = 6,
+ ColumnDefinitions =
+ {
+ new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star)
+ }
+ };
+ content.Add(slot, 0, 0);
+ content.Add(_icon, 1, 0);
+ content.Add(_label, 2, 0);
+
+ _row = new Border
+ {
+ Content = content,
+ HeightRequest = 32,
+ Padding = new Thickness(8, 0),
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ BackgroundColor = Colors.Transparent
+ };
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => { _hovered = true; Paint(); };
+ pointer.PointerExited += (_, _) => { _hovered = false; Paint(); };
+ _row.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { ShellFocus.FocusPressed(this); Press(); };
+ _row.GestureRecognizers.Add(tap);
+ ShellFocus.MakeFocusable(this, Press);
+
+ // ml-4 border-l pl-2: a hairline under the parent's chevron, children to its right.
+ var guide = new BoxView { WidthRequest = 1, BackgroundColor = Colors.Transparent, HorizontalOptions = LayoutOptions.Start, Margin = new Thickness(16, 0, 0, 0) };
+ guide.Token(BoxView.ColorProperty, ShellToken.Border);
+ _children = new VerticalStackLayout { Spacing = 2, Margin = new Thickness(25, 0, 0, 0) };
+ _children.ChildAdded += (_, _) => Update();
+ _children.ChildRemoved += (_, _) => Update();
+ _childrenHost = new Grid { Margin = new Thickness(0, 2, 0, 0), IsVisible = false, Children = { guide, _children } };
+
+ Content = new VerticalStackLayout { Spacing = 0, Children = { _row, _childrenHost } };
+ Update();
+ }
+
+ private bool HasChildren => _children.Children.Count > 0;
+
+ private void Press()
+ {
+ if (!IsEnabled) return;
+ if (HasChildren) IsExpanded = !IsExpanded;
+ if (this.FindParentOfType() is { } tree) tree.SelectedValue = EffectiveValue;
+ }
+
+ internal void ApplySelection(string? selected)
+ {
+ _selected = selected != null && selected == EffectiveValue;
+ Paint();
+ }
+
+ private void OnExpandedChanged() => Update();
+
+ private void Update()
+ {
+ _label.Text = Text ?? string.Empty;
+ _icon.Name = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ _chevron.IsVisible = HasChildren;
+ // A second icon rather than a rotation: a view rotated before its first layout pivots
+ // around its corner on Android and ends up outside its slot.
+ _chevron.Name = IsExpanded ? IconName.ChevronDown : IconName.ChevronRight;
+ _childrenHost.IsVisible = HasChildren && IsExpanded;
+ Paint();
+ }
+
+ private void Paint()
+ {
+ _label.FontAttributes = _selected ? FontAttributes.Bold : FontAttributes.None;
+ _label.Token(Label.TextColorProperty, _selected ? ShellToken.AccentForeground : ShellToken.Foreground);
+ if (_selected)
+ _row.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent);
+ else if (_hovered && IsEnabled)
+ _row.Token(VisualElement.BackgroundColorProperty, ShellToken.Muted);
+ else
+ {
+ _row.ClearValue(VisualElement.BackgroundColorProperty);
+ _row.BackgroundColor = Colors.Transparent;
+ }
+ }
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == IsEnabledProperty.PropertyName)
+ Opacity = IsEnabled ? 1.0 : 0.5;
+ }
+}
diff --git a/examples/MAUI.Demo/Components/UI/WrapLayout.cs b/examples/MAUI.Demo/Components/UI/WrapLayout.cs
new file mode 100644
index 0000000..9c0de8b
--- /dev/null
+++ b/examples/MAUI.Demo/Components/UI/WrapLayout.cs
@@ -0,0 +1,145 @@
+using Microsoft.Maui.Layouts;
+
+namespace MAUI.Demo.Components.UI;
+
+// flex flex-wrap gap-2 — children flow left to right and wrap onto new rows, centered vertically
+// within their row. Use it for button rows, chips and tag lists.
+//
+// ...
+//
+// Each child is measured once per layout pass.
+public partial class WrapLayout : Layout
+{
+ // Gap between children in a row.
+ public static readonly BindableProperty SpacingProperty =
+ BindableProperty.Create(nameof(Spacing), typeof(double), typeof(WrapLayout), 8d,
+ propertyChanged: (b, o, n) => ((WrapLayout)b).InvalidateMeasure());
+
+ // Gap between rows.
+ public static readonly BindableProperty LineSpacingProperty =
+ BindableProperty.Create(nameof(LineSpacing), typeof(double), typeof(WrapLayout), 8d,
+ propertyChanged: (b, o, n) => ((WrapLayout)b).InvalidateMeasure());
+
+ // The last child takes what is left of its row (or a row of its own when less than
+ // MinimumLastChildWidth remains) — e.g. the text field after a run of chips.
+ public static readonly BindableProperty LastChildFillProperty =
+ BindableProperty.Create(nameof(LastChildFill), typeof(bool), typeof(WrapLayout), false,
+ propertyChanged: (b, o, n) => ((WrapLayout)b).InvalidateMeasure());
+
+ public static readonly BindableProperty MinimumLastChildWidthProperty =
+ BindableProperty.Create(nameof(MinimumLastChildWidth), typeof(double), typeof(WrapLayout), 96d,
+ propertyChanged: (b, o, n) => ((WrapLayout)b).InvalidateMeasure());
+
+ public double Spacing
+ {
+ get => (double)GetValue(SpacingProperty);
+ set => SetValue(SpacingProperty, value);
+ }
+
+ public double LineSpacing
+ {
+ get => (double)GetValue(LineSpacingProperty);
+ set => SetValue(LineSpacingProperty, value);
+ }
+
+ public bool LastChildFill
+ {
+ get => (bool)GetValue(LastChildFillProperty);
+ set => SetValue(LastChildFillProperty, value);
+ }
+
+ public double MinimumLastChildWidth
+ {
+ get => (double)GetValue(MinimumLastChildWidthProperty);
+ set => SetValue(MinimumLastChildWidthProperty, value);
+ }
+
+ protected override ILayoutManager CreateLayoutManager() => new Manager(this);
+
+ private sealed class Manager(WrapLayout layout) : ILayoutManager
+ {
+ public Size Measure(double widthConstraint, double heightConstraint)
+ {
+ var padding = layout.Padding;
+ var bounded = !double.IsInfinity(widthConstraint);
+ var available = bounded ? Math.Max(0, widthConstraint - padding.HorizontalThickness) : double.PositiveInfinity;
+ var content = Flow(available, measure: true, origin: null);
+ return new Size(content.Width + padding.HorizontalThickness, content.Height + padding.VerticalThickness);
+ }
+
+ public Size ArrangeChildren(Rect bounds)
+ {
+ var padding = layout.Padding;
+ var available = Math.Max(0, bounds.Width - padding.HorizontalThickness);
+ // Sizes come from the measure pass: measuring again here would restart layout.
+ Flow(available, measure: false, origin: new Point(bounds.X + padding.Left, bounds.Y + padding.Top));
+ return bounds.Size;
+ }
+
+ private Size Flow(double available, bool measure, Point? origin)
+ {
+ var bounded = !double.IsInfinity(available);
+ var row = new List<(IView View, double X, double Width, double Height)>();
+ double x = 0, y = 0, rowHeight = 0, widest = 0;
+
+ var last = -1;
+ for (var i = layout.Count - 1; i >= 0 && last < 0; i--)
+ if (layout[i].Visibility != Visibility.Collapsed) last = i;
+
+ void EndRow()
+ {
+ if (origin is { } at)
+ {
+ foreach (var (view, left, width, height) in row)
+ view.Arrange(new Rect(at.X + left, at.Y + y + (rowHeight - height) / 2, width, height));
+ }
+ row.Clear();
+ y += rowHeight + layout.LineSpacing;
+ x = 0;
+ rowHeight = 0;
+ }
+
+ for (var i = 0; i <= last; i++)
+ {
+ var child = layout[i];
+ if (child.Visibility == Visibility.Collapsed) continue;
+
+ var start = row.Count > 0 ? x + layout.Spacing : 0;
+ double width, height;
+ if (layout.LastChildFill && i == last)
+ {
+ var rest = bounded ? available - start : layout.MinimumLastChildWidth;
+ if (bounded && rest < layout.MinimumLastChildWidth && row.Count > 0)
+ {
+ EndRow();
+ start = 0;
+ rest = available;
+ }
+ width = Math.Max(0, rest);
+ height = measure ? child.Measure(width, double.PositiveInfinity).Height : child.DesiredSize.Height;
+ }
+ else
+ {
+ var size = measure ? child.Measure(available, double.PositiveInfinity) : child.DesiredSize;
+ width = bounded ? Math.Min(size.Width, available) : size.Width;
+ height = size.Height;
+ // Half a pixel of slack so rounding can't wrap differently between passes.
+ if (bounded && row.Count > 0 && start + width > available + 0.5)
+ {
+ EndRow();
+ start = 0;
+ }
+ }
+
+ row.Add((child, start, width, height));
+ x = start + width;
+ rowHeight = Math.Max(rowHeight, height);
+ widest = Math.Max(widest, x);
+ }
+
+ var total = y + rowHeight;
+ if (row.Count > 0) EndRow();
+ return new Size(widest, last < 0 ? 0 : total);
+ }
+ }
+}
diff --git a/examples/MAUI.Demo/MainPage.xaml b/examples/MAUI.Demo/MainPage.xaml
index da5069b..f15b7d6 100644
--- a/examples/MAUI.Demo/MainPage.xaml
+++ b/examples/MAUI.Demo/MainPage.xaml
@@ -32,67 +32,97 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
+
+
-
-
+
+
-
+
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
@@ -100,8 +130,32 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -123,6 +177,15 @@
+
+
+
+
+
+
+
+
@@ -137,6 +200,12 @@
+
+
+
+
@@ -155,11 +224,11 @@
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -207,14 +297,14 @@
-
+
-
+
@@ -235,6 +325,9 @@
+
+
+
@@ -245,7 +338,7 @@
ConfirmVariant="Destructive" />
-
+
@@ -290,11 +383,11 @@
-
+
-
+
@@ -314,10 +407,10 @@
-
+
-
+
@@ -338,8 +431,31 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -406,6 +522,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -418,6 +556,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -428,6 +620,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/MAUI.Demo/MainPage.xaml.cs b/examples/MAUI.Demo/MainPage.xaml.cs
index db4817c..c00e870 100644
--- a/examples/MAUI.Demo/MainPage.xaml.cs
+++ b/examples/MAUI.Demo/MainPage.xaml.cs
@@ -10,6 +10,12 @@ public MainPage()
{
InitializeComponent();
CountrySelect.ItemsSource = new List { "United States", "Canada", "United Kingdom", "Germany", "Japan" };
+ TagsInput.Tags = new List { "maui", "design-system" };
+ FrameworkMultiSelect.ItemsSource = new List
+ {
+ ".NET MAUI", "Avalonia", "Blazor", "Uno Platform", "WinUI", "WPF", "Flutter", "React Native"
+ };
+ FrameworkMultiSelect.Values = new List { ".NET MAUI", "Blazor" };
FrameworkCombobox.ItemsSource = new List
{
".NET MAUI", "Avalonia", "Blazor", "Uno Platform", "WinUI", "WPF", "Flutter", "React Native", "SwiftUI", "Jetpack Compose"
@@ -20,10 +26,29 @@ public MainPage()
TagList.Children.Add(new Separator());
TagList.Children.Add(new Label { Text = tag, FontSize = 14 }.Token(Label.TextColorProperty, ShellToken.Foreground));
}
+
+ // Filling the samples above raised their change events; start with the hint instead.
+ Report("Interact with a component to see its events here.");
}
private void Report(string message) => StatusLabel.Text = message;
+ // One category at a time: hidden groups are not laid out.
+ private string _category = "buttons";
+ private void OnCategoryChanged(object? sender, string? value)
+ {
+ // A single-select toggle group can be cleared by tapping the pressed item; keep one on.
+ if (value is null) { CategoryPicker.Value = _category; return; }
+ _category = value;
+ GroupButtons.IsVisible = value == "buttons";
+ GroupForms.IsVisible = value == "forms";
+ GroupFeedback.IsVisible = value == "feedback";
+ GroupOverlays.IsVisible = value == "overlays";
+ GroupNavigation.IsVisible = value == "navigation";
+ GroupData.IsVisible = value == "data";
+ _ = PageScroll.ScrollToAsync(0, 0, false);
+ }
+
private void OnButtonClicked(object? sender, EventArgs e)
{
_clickCount++;
@@ -86,6 +111,33 @@ private void OnTimeChanged(object? sender, EventArgs e)
private void OnOtpCompleted(object? sender, string code) => Report($"OTP complete: {code}");
private void OnPageChanged(object? sender, int page) => Report($"Page: {page}");
+ private void OnToggleGroupChanged(object? sender, string? value) => Report($"Toggle group: {value ?? "none"}");
+ private void OnToggleGroupValues(object? sender, IReadOnlyList values) =>
+ Report($"Toggle group: {(values.Count == 0 ? "none" : string.Join(", ", values))}");
+ private void OnNumberChanged(object? sender, decimal? value) => Report($"Number: {(value.HasValue ? value.ToString() : "empty")}");
+ private void OnTagsChanged(object? sender, IReadOnlyList tags) => Report($"Tags: {string.Join(", ", tags)}");
+
+ private void OnCopied(object? sender, EventArgs e) => Report($"Copied: {(sender as CopyButton)?.Text}");
+ private void OnMultiSelectChanged(object? sender, IReadOnlyList values) =>
+ Report($"Multi select: {(values.Count == 0 ? "none" : string.Join(", ", values))}");
+ private void OnLinkCardClicked(object? sender, EventArgs e) => Report($"Link card: {(sender as LinkCard)?.Title}");
+ private void OnTreeSelected(object? sender, string? value) => Report($"Tree: {value}");
+
+ private void OnContextMenuItem(object? sender, EventArgs e) => Report($"Context menu: {(sender as ContextMenuItem)?.Text}");
+ private void OnStepChanged(object? sender, int step) => Report($"Stepper: step {step + 1}");
+ private void OnStepperConfirmed(object? sender, EventArgs e) => Toast.Success("Workspace created");
+ private void OnCarouselChanged(object? sender, int position) => Report($"Carousel: slide {position + 1}");
+
+ private TableRow? _selectedRow;
+ private void OnTableRowTapped(object? sender, EventArgs e)
+ {
+ if (sender is not TableRow row) return;
+ if (_selectedRow != null) _selectedRow.IsSelected = false;
+ _selectedRow = row;
+ row.IsSelected = true;
+ Report($"Table: {row.Children.OfType().FirstOrDefault()?.Text}");
+ }
+
private void OnDropdownItem(object? sender, EventArgs e) => Report($"Dropdown: {(sender as DropdownItem)?.Text}");
private void OnTabChanged(object? sender, (string Old, string New) e) => Report($"Tab: {e.New}");
diff --git a/scripts/generate-icons.py b/scripts/generate-icons.py
index ae33e54..0719f0e 100644
--- a/scripts/generate-icons.py
+++ b/scripts/generate-icons.py
@@ -207,7 +207,91 @@ def svg_to_path(svg: str) -> str:
parts.append(element_to_path(tag, dict(ATTR.findall(raw))))
if not parts:
raise ValueError("no shapes")
- return " ".join(parts)
+ return arcs_to_curves(" ".join(parts))
+
+
+def arc_to_cubics(x1, y1, rx, ry, rotation, large, sweep, x2, y2):
+ """SVG elliptical arc -> cubic Bezier segments (c1x, c1y, c2x, c2y, x, y), each <= 90 degrees."""
+ if (x1, y1) == (x2, y2):
+ return []
+ rx, ry = abs(rx), abs(ry)
+ if rx == 0 or ry == 0:
+ return [(x1, y1, x2, y2, x2, y2)]
+ phi = math.radians(rotation)
+ cos_phi, sin_phi = math.cos(phi), math.sin(phi)
+ dx, dy = (x1 - x2) / 2, (y1 - y2) / 2
+ x1p = cos_phi * dx + sin_phi * dy
+ y1p = -sin_phi * dx + cos_phi * dy
+ # Radii too small to span the endpoints are scaled up (SVG spec, F.6.6).
+ scale = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry)
+ if scale > 1:
+ rx, ry = rx * math.sqrt(scale), ry * math.sqrt(scale)
+ num = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p
+ den = rx * rx * y1p * y1p + ry * ry * x1p * x1p
+ coef = math.sqrt(max(0.0, num / den)) * (-1 if large == sweep else 1)
+ cxp, cyp = coef * rx * y1p / ry, -coef * ry * x1p / rx
+ cx = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2
+ cy = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2
+
+ def angle(ux, uy, vx, vy):
+ return math.atan2(ux * vy - uy * vx, ux * vx + uy * vy)
+
+ ux, uy = (x1p - cxp) / rx, (y1p - cyp) / ry
+ theta = angle(1, 0, ux, uy)
+ delta = angle(ux, uy, (-x1p - cxp) / rx, (-y1p - cyp) / ry)
+ if not sweep and delta > 0:
+ delta -= 2 * math.pi
+ elif sweep and delta < 0:
+ delta += 2 * math.pi
+
+ count = max(1, math.ceil(abs(delta) / (math.pi / 2) - 1e-9))
+ step = delta / count
+ t = 4 / 3 * math.tan(step / 4)
+
+ def point(px, py):
+ return (cx + rx * cos_phi * px - ry * sin_phi * py, cy + rx * sin_phi * px + ry * cos_phi * py)
+
+ curves = []
+ for i in range(count):
+ a1 = theta + i * step
+ a2 = a1 + step
+ c1 = point(math.cos(a1) - t * math.sin(a1), math.sin(a1) + t * math.cos(a1))
+ c2 = point(math.cos(a2) + t * math.sin(a2), math.sin(a2) - t * math.cos(a2))
+ end = (x2, y2) if i == count - 1 else point(math.cos(a2), math.sin(a2))
+ curves.append((*c1, *c2, *end))
+ return curves
+
+
+def arcs_to_curves(d: str) -> str:
+ """Rewrite every arc (A) in an absolute path as cubic Beziers (C).
+
+ The output then only uses M, L, C and Z, which every platform draws the same way. Arc
+ handling differs per platform and is the fragile part: on Windows MAUI fails to draw some
+ arcs at all (e.g. the radius-0.25 arcs in "activity"), which takes the app down.
+ """
+ tokens = d.split()
+ sizes = {"M": 2, "L": 2, "T": 2, "C": 6, "S": 4, "Q": 4, "A": 7, "Z": 0}
+ out = []
+ x = y = sx = sy = 0.0
+ i = 0
+ while i < len(tokens):
+ cmd = tokens[i]
+ args = [float(v) for v in tokens[i + 1:i + 1 + sizes[cmd]]]
+ i += 1 + sizes[cmd]
+ if cmd == "A":
+ rx, ry, rotation, large, sweep, nx, ny = args
+ for curve in arc_to_cubics(x, y, rx, ry, rotation, bool(large), bool(sweep), nx, ny):
+ out.append("C " + " ".join(fmt(v) for v in curve))
+ x, y = nx, ny
+ continue
+ out.append(" ".join([cmd] + [fmt(v) for v in args]))
+ if cmd == "Z":
+ x, y = sx, sy
+ else:
+ x, y = args[-2], args[-1]
+ if cmd == "M":
+ sx, sy = x, y
+ return " ".join(out)
def pascal(kebab: str) -> str:
diff --git a/scripts/sync-templates.py b/scripts/sync-templates.py
index 7d3e24a..9218212 100644
--- a/scripts/sync-templates.py
+++ b/scripts/sync-templates.py
@@ -49,6 +49,38 @@
["callout", "note", "admonition", "info"]),
"Combobox": ("combobox", "Combobox", "Searchable select with a filter field", "Form",
["combobox", "select", "search", "autocomplete"]),
+ "Table": ("table", "Table", "Data table with header, rows, hover and optional caption", "DataDisplay",
+ ["table", "data", "grid", "rows"]),
+ "ContextMenu": ("context-menu", "Context Menu", "Right-click / long-press menu that opens at the pointer", "Overlay",
+ ["context", "menu", "right-click", "long-press"]),
+ "Carousel": ("carousel", "Carousel", "Swipeable slides with arrows, dots and auto-play", "DataDisplay",
+ ["carousel", "slider", "slides", "gallery"]),
+ "Stepper": ("stepper", "Stepper", "Step-by-step flow with numbered steps and navigation", "Navigation",
+ ["stepper", "wizard", "steps", "progress"]),
+ "ToggleGroup": ("toggle-group", "Toggle Group", "Row of toggles with single or multiple selection", "Form",
+ ["toggle", "group", "segmented", "selection"]),
+ "NumberInput": ("number-input", "Number Input", "Number field with decrement and increment buttons", "Form",
+ ["number", "input", "stepper", "quantity"]),
+ "TagInput": ("tag-input", "Tag Input", "Text field that turns entries into removable tags", "Form",
+ ["tags", "chips", "input", "labels"]),
+ "Kbd": ("kbd", "Kbd", "Keyboard key hint", "DataDisplay",
+ ["keyboard", "shortcut", "key"]),
+ "StatCard": ("stat-card", "Stat Card", "Dashboard metric with trend and description", "DataDisplay",
+ ["stat", "metric", "kpi", "dashboard"]),
+ "Timeline": ("timeline", "Timeline", "Vertical list of events joined by a line", "DataDisplay",
+ ["timeline", "history", "activity", "steps"]),
+ "WrapLayout": ("wrap-layout", "Wrap Layout", "Children flow left to right and wrap onto new rows", "Layout",
+ ["wrap", "flow", "flex", "layout"]),
+ "CopyButton": ("copy-button", "Copy Button", "Copies text to the clipboard and confirms with a check", "Utility",
+ ["copy", "clipboard", "button"]),
+ "AspectRatio": ("aspect-ratio", "Aspect Ratio", "Keeps content at a fixed width / height ratio", "Layout",
+ ["aspect", "ratio", "layout", "media"]),
+ "LinkCard": ("link-card", "Link Card", "Tappable card with icon, title and description", "Navigation",
+ ["link", "card", "navigation"]),
+ "TreeView": ("tree-view", "Tree View", "Expandable tree of rows with selection", "Navigation",
+ ["tree", "hierarchy", "files", "navigation"]),
+ "MultiSelect": ("multi-select", "Multi Select", "Searchable select for several options, shown as chips", "Form",
+ ["select", "multiple", "chips", "search"]),
"EmptyState": ("empty-state", "Empty State", "Placeholder for an empty list or screen", "DataDisplay",
["empty", "placeholder", "state"]),
}
@@ -57,6 +89,8 @@
EXTRA_DEPS = {
"HoverCard": ["hover-card-trigger", "hover-card-content"],
"DatePicker": ["calendar"],
+ "TagInput": ["wrap-layout"],
+ "MultiSelect": ["wrap-layout"],
}
SHELL_API = re.compile(r"\bShellTheme\b|\.Token\(|\bShellToken\b|\bShellFocus\b|\bShellPlatform\b|\bShellPopups\b|"
diff --git a/src/ShellUI.Native.Templates/ComponentRegistry.cs b/src/ShellUI.Native.Templates/ComponentRegistry.cs
index c9d2cc8..1c3e2c2 100644
--- a/src/ShellUI.Native.Templates/ComponentRegistry.cs
+++ b/src/ShellUI.Native.Templates/ComponentRegistry.cs
@@ -105,7 +105,23 @@ public static class ComponentRegistry
{ "pagination", (PaginationTemplate.Metadata, PaginationTemplate.Contents) },
{ "empty-state", (EmptyStateTemplate.Metadata, EmptyStateTemplate.Contents) },
{ "callout", (CalloutTemplate.Metadata, CalloutTemplate.Contents) },
- { "combobox", (ComboboxTemplate.Metadata, ComboboxTemplate.Contents) }
+ { "combobox", (ComboboxTemplate.Metadata, ComboboxTemplate.Contents) },
+ { "table", (TableTemplate.Metadata, TableTemplate.Contents) },
+ { "context-menu", (ContextMenuTemplate.Metadata, ContextMenuTemplate.Contents) },
+ { "carousel", (CarouselTemplate.Metadata, CarouselTemplate.Contents) },
+ { "stepper", (StepperTemplate.Metadata, StepperTemplate.Contents) },
+ { "toggle-group", (ToggleGroupTemplate.Metadata, ToggleGroupTemplate.Contents) },
+ { "number-input", (NumberInputTemplate.Metadata, NumberInputTemplate.Contents) },
+ { "tag-input", (TagInputTemplate.Metadata, TagInputTemplate.Contents) },
+ { "kbd", (KbdTemplate.Metadata, KbdTemplate.Contents) },
+ { "stat-card", (StatCardTemplate.Metadata, StatCardTemplate.Contents) },
+ { "timeline", (TimelineTemplate.Metadata, TimelineTemplate.Contents) },
+ { "wrap-layout", (WrapLayoutTemplate.Metadata, WrapLayoutTemplate.Contents) },
+ { "copy-button", (CopyButtonTemplate.Metadata, CopyButtonTemplate.Contents) },
+ { "aspect-ratio", (AspectRatioTemplate.Metadata, AspectRatioTemplate.Contents) },
+ { "link-card", (LinkCardTemplate.Metadata, LinkCardTemplate.Contents) },
+ { "tree-view", (TreeViewTemplate.Metadata, TreeViewTemplate.Contents) },
+ { "multi-select", (MultiSelectTemplate.Metadata, MultiSelectTemplate.Contents) }
};
// Metadata-only view for callers that don't need the per-platform payload.
diff --git a/src/ShellUI.Native.Templates/Templates/AspectRatioTemplate.cs b/src/ShellUI.Native.Templates/Templates/AspectRatioTemplate.cs
new file mode 100644
index 0000000..507c648
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/AspectRatioTemplate.cs
@@ -0,0 +1,55 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class AspectRatioTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "aspect-ratio",
+ DisplayName = "Aspect Ratio",
+ Description = "Keeps content at a fixed width / height ratio",
+ Category = ComponentCategory.Layout,
+ FilePath = "AspectRatio.cs",
+ Dependencies = new List(),
+ Tags = new List { "aspect", "ratio", "layout", "media" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"namespace YourProjectNamespace.Components.UI;
+
+// Keeps its content at a fixed width / height ratio: the height follows the width.
+//
+//
+//
+public partial class AspectRatio : ContentView
+{
+ // Width divided by height. 16:9 is 1.7778, 4:3 is 1.3333, a square is 1.
+ public static readonly BindableProperty RatioProperty =
+ BindableProperty.Create(nameof(Ratio), typeof(double), typeof(AspectRatio), 16d / 9d,
+ propertyChanged: (b, o, n) => ((AspectRatio)b).Fit());
+
+ public double Ratio
+ {
+ get => (double)GetValue(RatioProperty);
+ set => SetValue(RatioProperty, value);
+ }
+
+ public AspectRatio()
+ {
+ IsClippedToBounds = true;
+ SizeChanged += (_, _) => Fit();
+ }
+
+ private void Fit()
+ {
+ if (Width <= 0 || Ratio <= 0) return;
+ var height = Width / Ratio;
+ // Only when it actually changes: setting it again would ask for another layout pass.
+ if (Math.Abs(HeightRequest - height) > 0.5) HeightRequest = height;
+ }
+}
+"
+ };
+}
diff --git a/src/ShellUI.Native.Templates/Templates/CarouselTemplate.cs b/src/ShellUI.Native.Templates/Templates/CarouselTemplate.cs
new file mode 100644
index 0000000..d5c3064
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/CarouselTemplate.cs
@@ -0,0 +1,324 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class CarouselTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "carousel",
+ DisplayName = "Carousel",
+ Description = "Swipeable slides with arrows, dots and auto-play",
+ Category = ComponentCategory.DataDisplay,
+ FilePath = "Carousel.cs",
+ Dependencies = new List { "shell", "icon" },
+ Tags = new List { "carousel", "slider", "slides", "gallery" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"using Microsoft.Maui.Controls.Shapes;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Carousel — one slide at a time in a rounded-lg frame; swipe, use the round arrow buttons or
+// tap a dot to move. Each child view is a slide.
+//
+//
+//
+//
+[ContentProperty(nameof(Slides))]
+public partial class Carousel : ContentView
+{
+ public static readonly BindableProperty PositionProperty =
+ BindableProperty.Create(nameof(Position), typeof(int), typeof(Carousel), 0, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((Carousel)b).OnPositionChanged());
+
+ // At the last slide, Next wraps to the first (and Previous the other way).
+ public static readonly BindableProperty LoopProperty =
+ BindableProperty.Create(nameof(Loop), typeof(bool), typeof(Carousel), true,
+ propertyChanged: (b, o, n) => ((Carousel)b).UpdateControls());
+
+ public static readonly BindableProperty ShowArrowsProperty =
+ BindableProperty.Create(nameof(ShowArrows), typeof(bool), typeof(Carousel), true,
+ propertyChanged: (b, o, n) => ((Carousel)b).UpdateControls());
+
+ public static readonly BindableProperty ShowDotsProperty =
+ BindableProperty.Create(nameof(ShowDots), typeof(bool), typeof(Carousel), true,
+ propertyChanged: (b, o, n) => ((Carousel)b).UpdateControls());
+
+ public static readonly BindableProperty AutoPlayProperty =
+ BindableProperty.Create(nameof(AutoPlay), typeof(bool), typeof(Carousel), false,
+ propertyChanged: (b, o, n) => ((Carousel)b).UpdateTimer());
+
+ // Milliseconds between slides while AutoPlay is on.
+ public static readonly BindableProperty AutoPlayIntervalProperty =
+ BindableProperty.Create(nameof(AutoPlayInterval), typeof(int), typeof(Carousel), 3000,
+ propertyChanged: (b, o, n) => ((Carousel)b).UpdateTimer());
+
+ // Width / height of the frame. 0 turns it off: set HeightRequest on the carousel instead.
+ public static readonly BindableProperty AspectRatioProperty =
+ BindableProperty.Create(nameof(AspectRatio), typeof(double), typeof(Carousel), 16d / 9d,
+ propertyChanged: (b, o, n) => ((Carousel)b).Resize());
+
+ public int Position
+ {
+ get => (int)GetValue(PositionProperty);
+ set => SetValue(PositionProperty, value);
+ }
+
+ public bool Loop
+ {
+ get => (bool)GetValue(LoopProperty);
+ set => SetValue(LoopProperty, value);
+ }
+
+ public bool ShowArrows
+ {
+ get => (bool)GetValue(ShowArrowsProperty);
+ set => SetValue(ShowArrowsProperty, value);
+ }
+
+ public bool ShowDots
+ {
+ get => (bool)GetValue(ShowDotsProperty);
+ set => SetValue(ShowDotsProperty, value);
+ }
+
+ public bool AutoPlay
+ {
+ get => (bool)GetValue(AutoPlayProperty);
+ set => SetValue(AutoPlayProperty, value);
+ }
+
+ public int AutoPlayInterval
+ {
+ get => (int)GetValue(AutoPlayIntervalProperty);
+ set => SetValue(AutoPlayIntervalProperty, value);
+ }
+
+ public double AspectRatio
+ {
+ get => (double)GetValue(AspectRatioProperty);
+ set => SetValue(AspectRatioProperty, value);
+ }
+
+ public IList Slides => _track.Children;
+
+ public event EventHandler? PositionChanged;
+
+ private const string SlideAnimation = ""CarouselSlide"";
+
+ private readonly Grid _track;
+ private readonly Border _frame;
+ private readonly Border _previous;
+ private readonly Border _next;
+ private readonly HorizontalStackLayout _dots;
+ private IDispatcherTimer? _timer;
+ // Where the track is, in slides: 1.5 is halfway between the second and third slide.
+ private double _offset;
+ private double _dragX;
+
+ public Carousel()
+ {
+ _track = new Grid { IsClippedToBounds = true, BackgroundColor = Colors.Transparent };
+ _track.ChildAdded += (_, _) => OnSlidesChanged();
+ _track.ChildRemoved += (_, _) => OnSlidesChanged();
+ var pan = new PanGestureRecognizer();
+ pan.PanUpdated += OnPan;
+ _track.GestureRecognizers.Add(pan);
+
+ _frame = new Border
+ {
+ Content = _track,
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusLg },
+ BackgroundColor = Colors.Transparent
+ };
+
+ _previous = Arrow(IconName.ChevronLeft, LayoutOptions.Start, ""Previous slide"", Previous);
+ _next = Arrow(IconName.ChevronRight, LayoutOptions.End, ""Next slide"", Next);
+ _dots = new HorizontalStackLayout
+ {
+ Spacing = 0,
+ HorizontalOptions = LayoutOptions.Center,
+ VerticalOptions = LayoutOptions.End,
+ Margin = new Thickness(0, 0, 0, 8)
+ };
+
+ Content = new Grid { Children = { _frame, _previous, _next, _dots } };
+ SizeChanged += (_, _) => Resize();
+ Loaded += (_, _) => UpdateTimer();
+ Unloaded += (_, _) => StopTimer();
+ UpdateControls();
+ }
+
+ private int Count => _track.Children.Count;
+ private int Current => Math.Clamp(Position, 0, Math.Max(0, Count - 1));
+
+ public void Next()
+ {
+ if (Current < Count - 1) Position = Current + 1;
+ else if (Loop && Count > 1) Position = 0;
+ }
+
+ public void Previous()
+ {
+ if (Current > 0) Position = Current - 1;
+ else if (Loop && Count > 1) Position = Count - 1;
+ }
+
+ private Border Arrow(IconName icon, LayoutOptions side, string description, Action press)
+ {
+ var button = new Border
+ {
+ Content = new Icon { Name = icon, Size = 16, Token = ShellToken.Foreground },
+ WidthRequest = 32,
+ HeightRequest = 32,
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = 16 },
+ HorizontalOptions = side,
+ VerticalOptions = LayoutOptions.Center,
+ Margin = new Thickness(12, 0),
+ Shadow = new Shadow { Brush = new SolidColorBrush(Colors.Black), Offset = new Point(0, 2), Radius = 6, Opacity = 0.15f }
+ };
+ button.Token(VisualElement.BackgroundColorProperty, ShellToken.Background);
+ button.Token(Border.StrokeProperty, ShellToken.Border);
+ SemanticProperties.SetDescription(button, description);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => press();
+ button.GestureRecognizers.Add(tap);
+ return button;
+ }
+
+ private void OnSlidesChanged()
+ {
+ _dots.Children.Clear();
+ for (var i = 0; i < Count; i++)
+ {
+ var index = i;
+ var dot = new Border
+ {
+ WidthRequest = 8,
+ HeightRequest = 8,
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = 4 },
+ HorizontalOptions = LayoutOptions.Center,
+ VerticalOptions = LayoutOptions.Center
+ };
+ dot.Token(VisualElement.BackgroundColorProperty, ShellToken.Primary);
+ // 8px dot in a 16px tap target.
+ var target = new Grid { WidthRequest = 16, HeightRequest = 16, BackgroundColor = Colors.Transparent, Children = { dot } };
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => Position = index;
+ target.GestureRecognizers.Add(tap);
+ _dots.Children.Add(target);
+ }
+ _offset = Current;
+ Render();
+ UpdateControls();
+ }
+
+ private void OnPositionChanged()
+ {
+ UpdateControls();
+ this.AbortAnimation(SlideAnimation);
+ new Animation(v => { _offset = v; Render(); }, _offset, Current)
+ .Commit(this, SlideAnimation, 16, 300, Easing.CubicOut);
+ PositionChanged?.Invoke(this, Current);
+ // A manual move restarts the auto-play countdown.
+ UpdateTimer();
+ }
+
+ private void Resize()
+ {
+ if (AspectRatio > 0 && Width > 0) _frame.HeightRequest = Width / AspectRatio;
+ else if (AspectRatio <= 0) _frame.HeightRequest = -1;
+ Render();
+ }
+
+ // Slides are stacked in one cell and spread out sideways by translation.
+ private void Render()
+ {
+ var width = _track.Width > 0 ? _track.Width : Width;
+ if (width <= 0) return;
+ for (var i = 0; i < Count; i++)
+ {
+ if (_track.Children[i] is View slide)
+ slide.TranslationX = (i - _offset) * width;
+ }
+ }
+
+ private void UpdateControls()
+ {
+ var current = Current;
+ var many = Count > 1;
+ _previous.IsVisible = ShowArrows && many;
+ _next.IsVisible = ShowArrows && many;
+ _previous.Opacity = Loop || current > 0 ? 0.9 : 0.4;
+ _next.Opacity = Loop || current < Count - 1 ? 0.9 : 0.4;
+ _dots.IsVisible = ShowDots && many;
+ for (var i = 0; i < _dots.Children.Count; i++)
+ {
+ // One color at two strengths, so the dots stay readable over any slide.
+ if (_dots.Children[i] is Grid { Children: [Border dot] })
+ dot.Opacity = i == current ? 1 : 0.3;
+ }
+ }
+
+ private void OnPan(object? sender, PanUpdatedEventArgs e)
+ {
+ var width = _track.Width;
+ if (width <= 0 || Count < 2) return;
+ switch (e.StatusType)
+ {
+ case GestureStatus.Started:
+ this.AbortAnimation(SlideAnimation);
+ _dragX = 0;
+ break;
+ case GestureStatus.Running:
+ // Follow the finger, with a little give past the first and last slide.
+ _dragX = e.TotalX;
+ _offset = Math.Clamp(Current - _dragX / width, -0.25, Count - 0.75);
+ Render();
+ break;
+ case GestureStatus.Completed:
+ case GestureStatus.Canceled:
+ var moved = _dragX / width;
+ _dragX = 0;
+ var current = Current;
+ if (moved <= -0.2 && current < Count - 1) Position = current + 1;
+ else if (moved >= 0.2 && current > 0) Position = current - 1;
+ else
+ {
+ // Not far enough: settle back.
+ new Animation(v => { _offset = v; Render(); }, _offset, current)
+ .Commit(this, SlideAnimation, 16, 200, Easing.CubicOut);
+ }
+ break;
+ }
+ }
+
+ private void UpdateTimer()
+ {
+ StopTimer();
+ if (!AutoPlay || Count < 2 || Handler is null) return;
+ _timer = Dispatcher.CreateTimer();
+ _timer.Interval = TimeSpan.FromMilliseconds(Math.Max(500, AutoPlayInterval));
+ _timer.Tick += (_, _) =>
+ {
+ if (Current < Count - 1) Position = Current + 1;
+ else Position = 0;
+ };
+ _timer.Start();
+ }
+
+ private void StopTimer()
+ {
+ _timer?.Stop();
+ _timer = null;
+ }
+}
+"
+ };
+}
diff --git a/src/ShellUI.Native.Templates/Templates/ContextMenuTemplate.cs b/src/ShellUI.Native.Templates/Templates/ContextMenuTemplate.cs
new file mode 100644
index 0000000..01950af
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/ContextMenuTemplate.cs
@@ -0,0 +1,266 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class ContextMenuTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "context-menu",
+ DisplayName = "Context Menu",
+ Description = "Right-click / long-press menu that opens at the pointer",
+ Category = ComponentCategory.Overlay,
+ FilePath = "ContextMenu.cs",
+ Dependencies = new List { "shell", "icon", "element-extensions" },
+ Tags = new List { "context", "menu", "right-click", "long-press" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"using Microsoft.Maui.Controls.Shapes;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Context menu — right-click (long-press on touch) the trigger to open a menu at the pointer.
+// Clicking outside, picking an item or pressing Escape closes it.
+//
+// ...any view...
+//
+//
+//
+//
+//
+//
+public partial class ContextMenu : ShellPopoverHost
+{
+ private Point? _point;
+
+ public ContextMenu()
+ {
+ // The trigger usually is a surface (a card, a row): let it fill its container.
+ HorizontalOptions = LayoutOptions.Fill;
+ }
+
+ protected override bool IsContent(Element child) => child is ContextMenuContent;
+ protected override Point? AnchorPoint => _point;
+ protected override double Offset => _point is null ? 4 : 0;
+
+ // Opens the menu at a point relative to this view, or below the trigger when null.
+ public void OpenAt(Point? point)
+ {
+ if (IsOpen) IsOpen = false;
+ _point = point;
+ IsOpen = true;
+ }
+}
+
+// The surface that opens the enclosing ContextMenu on right-click / long-press.
+public partial class ContextMenuTrigger : ContentView
+{
+#if ANDROID || IOS || MACCATALYST
+ private object? _hooked;
+#endif
+
+ public ContextMenuTrigger()
+ {
+ var secondary = new TapGestureRecognizer { Buttons = ButtonsMask.Secondary };
+ secondary.Tapped += (_, e) =>
+ {
+ var menu = this.FindParentOfType();
+ menu?.OpenAt(e.GetPosition(menu));
+ };
+ GestureRecognizers.Add(secondary);
+ HandlerChanged += (_, _) => HookLongPress();
+ }
+
+ private void Open(Point? point) => this.FindParentOfType()?.OpenAt(point);
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName != nameof(Content)) return;
+ if (Content is View content) content.HandlerChanged += (_, _) => HookLongPress();
+ HookLongPress();
+ }
+
+ private void HookLongPress()
+ {
+#if ANDROID
+ // Listen on the content, not on this view: this view's gesture handling takes the touch
+ // stream before Android's own long-press detection would see it.
+ var native = (Content as View)?.Handler?.PlatformView as Android.Views.View
+ ?? Handler?.PlatformView as Android.Views.View;
+ if (native is null || ReferenceEquals(native, _hooked)) return;
+ _hooked = native;
+ native.LongClickable = true;
+ native.LongClick += (_, e) => { e.Handled = true; Open(null); };
+#elif IOS || MACCATALYST
+ if (Handler?.PlatformView is not UIKit.UIView native || ReferenceEquals(native, _hooked)) return;
+ _hooked = native;
+ native.AddGestureRecognizer(new UIKit.UILongPressGestureRecognizer(gesture =>
+ {
+ if (gesture.State == UIKit.UIGestureRecognizerState.Began) Open(null);
+ }));
+#endif
+ }
+}
+
+// Menu panel — min-w-[13rem] rounded-md border bg-popover p-1 shadow-md.
+[ContentProperty(nameof(Children))]
+public partial class ContextMenuContent : ContentView
+{
+ private readonly VerticalStackLayout _stack;
+
+ public new IList Children => _stack.Children;
+
+ public ContextMenuContent()
+ {
+ _stack = new VerticalStackLayout { Spacing = 0 };
+ var panel = new Border
+ {
+ Content = _stack,
+ Padding = new Thickness(4),
+ MinimumWidthRequest = 208,
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ Shadow = ShellPopups.PanelShadow()
+ };
+ panel.Token(VisualElement.BackgroundColorProperty, ShellToken.Popover);
+ panel.Token(Border.StrokeProperty, ShellToken.Border);
+ // Taps on the panel padding must not fall through to the click-outside catcher.
+ panel.GestureRecognizers.Add(new TapGestureRecognizer());
+ Content = panel;
+ }
+}
+
+// Menu row — rounded-sm px-2 py-1.5 text-sm, hover:bg-accent, optional icon and shortcut hint.
+public partial class ContextMenuItem : ContentView
+{
+ public static readonly BindableProperty TextProperty =
+ BindableProperty.Create(nameof(Text), typeof(string), typeof(ContextMenuItem), string.Empty,
+ propertyChanged: (b, o, n) => ((ContextMenuItem)b).Update());
+
+ public static readonly BindableProperty IconProperty =
+ BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(ContextMenuItem), IconName.None,
+ propertyChanged: (b, o, n) => ((ContextMenuItem)b).Update());
+
+ // Keyboard hint shown at the end of the row (display only).
+ public static readonly BindableProperty ShortcutProperty =
+ BindableProperty.Create(nameof(Shortcut), typeof(string), typeof(ContextMenuItem), string.Empty,
+ propertyChanged: (b, o, n) => ((ContextMenuItem)b).Update());
+
+ public static readonly BindableProperty IsDestructiveProperty =
+ BindableProperty.Create(nameof(IsDestructive), typeof(bool), typeof(ContextMenuItem), false,
+ propertyChanged: (b, o, n) => ((ContextMenuItem)b).Update());
+
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => (IconName)GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public string Shortcut
+ {
+ get => (string)GetValue(ShortcutProperty);
+ set => SetValue(ShortcutProperty, value);
+ }
+
+ public bool IsDestructive
+ {
+ get => (bool)GetValue(IsDestructiveProperty);
+ set => SetValue(IsDestructiveProperty, value);
+ }
+
+ public event EventHandler? Clicked;
+
+ private readonly Border _row;
+ private readonly Icon _icon;
+ private readonly Label _label;
+ private readonly Label _shortcut;
+
+ public ContextMenuItem()
+ {
+ _icon = new Icon { Size = 16, IsVisible = false, VerticalOptions = LayoutOptions.Center };
+ _label = new Label { FontSize = 14, VerticalOptions = LayoutOptions.Center, VerticalTextAlignment = TextAlignment.Center };
+ _shortcut = new Label { FontSize = 12, VerticalOptions = LayoutOptions.Center, VerticalTextAlignment = TextAlignment.Center, IsVisible = false };
+ _shortcut.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+
+ var grid = new Grid
+ {
+ ColumnSpacing = 8,
+ ColumnDefinitions =
+ {
+ new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto)
+ }
+ };
+ grid.Add(_icon, 0, 0);
+ grid.Add(_label, 1, 0);
+ grid.Add(_shortcut, 2, 0);
+
+ _row = new Border
+ {
+ Content = grid,
+ HeightRequest = 32,
+ Padding = new Thickness(8, 0),
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusSm },
+ BackgroundColor = Colors.Transparent
+ };
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => { if (IsEnabled) _row.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent); };
+ pointer.PointerExited += (_, _) => { _row.ClearValue(VisualElement.BackgroundColorProperty); _row.BackgroundColor = Colors.Transparent; };
+ _row.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => Press();
+ _row.GestureRecognizers.Add(tap);
+
+ Content = _row;
+ Update();
+ }
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == nameof(IsEnabled)) Opacity = IsEnabled ? 1 : 0.5;
+ }
+
+ private void Press()
+ {
+ if (!IsEnabled) return;
+ this.FindParentOfType()?.Close();
+ Clicked?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void Update()
+ {
+ var color = IsDestructive ? ShellToken.Destructive : ShellToken.PopoverForeground;
+ _icon.Name = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ _icon.Token = color;
+ _label.Text = Text ?? string.Empty;
+ _label.Token(Label.TextColorProperty, color);
+ _shortcut.Text = Shortcut ?? string.Empty;
+ _shortcut.IsVisible = !string.IsNullOrEmpty(Shortcut);
+ }
+}
+
+// Divider between groups of items — -mx-1 my-1 h-px bg-border.
+public partial class ContextMenuSeparator : ContentView
+{
+ public ContextMenuSeparator()
+ {
+ var line = new BoxView { HeightRequest = 1, BackgroundColor = Colors.Transparent };
+ line.Token(BoxView.ColorProperty, ShellToken.Border);
+ Margin = new Thickness(-4, 4);
+ Content = line;
+ }
+}
+"
+ };
+}
diff --git a/src/ShellUI.Native.Templates/Templates/CopyButtonTemplate.cs b/src/ShellUI.Native.Templates/Templates/CopyButtonTemplate.cs
new file mode 100644
index 0000000..f262214
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/CopyButtonTemplate.cs
@@ -0,0 +1,154 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class CopyButtonTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "copy-button",
+ DisplayName = "Copy Button",
+ Description = "Copies text to the clipboard and confirms with a check",
+ Category = ComponentCategory.Utility,
+ FilePath = "CopyButton.cs",
+ Dependencies = new List { "shell", "icon" },
+ Tags = new List { "copy", "clipboard", "button" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"using Microsoft.Maui.Controls.Shapes;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Copies Text to the clipboard — a ghost icon button (h-9 w-9 rounded-md, hover:bg-accent) whose
+// icon turns into a check for two seconds after copying. Label adds text next to the icon.
+// Usage:
+public partial class CopyButton : ContentView
+{
+ public static readonly BindableProperty TextProperty =
+ BindableProperty.Create(nameof(Text), typeof(string), typeof(CopyButton), string.Empty,
+ propertyChanged: (b, o, n) => ((CopyButton)b).Update());
+
+ public static readonly BindableProperty LabelProperty =
+ BindableProperty.Create(nameof(Label), typeof(string), typeof(CopyButton), string.Empty,
+ propertyChanged: (b, o, n) => ((CopyButton)b).Update());
+
+ // The text placed on the clipboard.
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public string Label
+ {
+ get => (string)GetValue(LabelProperty);
+ set => SetValue(LabelProperty, value);
+ }
+
+ public event EventHandler? Copied;
+
+ private readonly Border _border;
+ private readonly Icon _icon;
+ private readonly Label _label;
+ private bool _copied;
+ private bool _hovered;
+ private int _version;
+
+ public CopyButton()
+ {
+ _icon = new Icon { Name = IconName.Copy, Size = 16 };
+ _label = new Label
+ {
+ FontSize = 14,
+ FontAttributes = FontAttributes.Bold,
+ VerticalTextAlignment = TextAlignment.Center,
+ LineBreakMode = LineBreakMode.NoWrap,
+ IsVisible = false
+ };
+ _border = new Border
+ {
+ Content = new HorizontalStackLayout
+ {
+ Spacing = 8,
+ HorizontalOptions = LayoutOptions.Center,
+ VerticalOptions = LayoutOptions.Center,
+ Children = { _icon, _label }
+ },
+ HeightRequest = 36,
+ MinimumWidthRequest = 36,
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ BackgroundColor = Colors.Transparent
+ };
+
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => { _hovered = true; Update(); };
+ pointer.PointerExited += (_, _) => { _hovered = false; Update(); };
+ _border.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { ShellFocus.FocusPressed(this); _ = CopyAsync(); };
+ _border.GestureRecognizers.Add(tap);
+ ShellFocus.MakeFocusable(this, () => _ = CopyAsync());
+
+ Content = _border;
+ HorizontalOptions = LayoutOptions.Start;
+ Update();
+ }
+
+ public async Task CopyAsync()
+ {
+ if (!IsEnabled || string.IsNullOrEmpty(Text)) return;
+ try
+ {
+ await Clipboard.Default.SetTextAsync(Text);
+ }
+ catch (Exception)
+ {
+ // No clipboard access (e.g. a locked-down device): leave the icon as it is.
+ return;
+ }
+
+ var version = ++_version;
+ _copied = true;
+ Update();
+ Copied?.Invoke(this, EventArgs.Empty);
+ await Task.Delay(2000);
+ if (version != _version) return;
+ _copied = false;
+ Update();
+ }
+
+ private void Update()
+ {
+ var hasLabel = !string.IsNullOrEmpty(Label);
+ _label.Text = Label ?? string.Empty;
+ _label.IsVisible = hasLabel;
+ _border.Padding = new Thickness(hasLabel ? 12 : 0, 0);
+
+ _icon.Name = _copied ? IconName.Check : IconName.Copy;
+ var foreground = _copied ? ShellToken.Success : _hovered ? ShellToken.AccentForeground : ShellToken.Foreground;
+ _icon.Token = foreground;
+ _label.Token(Microsoft.Maui.Controls.Label.TextColorProperty, _hovered ? ShellToken.AccentForeground : ShellToken.Foreground);
+
+ if (_hovered && IsEnabled)
+ _border.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent);
+ else
+ {
+ _border.ClearValue(VisualElement.BackgroundColorProperty);
+ _border.BackgroundColor = Colors.Transparent;
+ }
+ Opacity = IsEnabled && !string.IsNullOrEmpty(Text) ? 1.0 : 0.5;
+ SemanticProperties.SetDescription(this, _copied ? ""Copied"" : ""Copy to clipboard"");
+ }
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == IsEnabledProperty.PropertyName && _border != null) Update();
+ }
+}
+"
+ };
+}
diff --git a/src/ShellUI.Native.Templates/Templates/IconTemplate.cs b/src/ShellUI.Native.Templates/Templates/IconTemplate.cs
index 38af6b7..02cc764 100644
--- a/src/ShellUI.Native.Templates/Templates/IconTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/IconTemplate.cs
@@ -150,30 +150,30 @@ public static class IconPaths
IconName.X => ""M 18 6 L 6 18 M 6 6 L 18 18"",
IconName.Plus => ""M 5 12 L 19 12 M 12 5 L 12 19"",
IconName.Minus => ""M 5 12 L 19 12"",
- IconName.Search => ""M 19 11 A 8 8 0 1 1 3 11 A 8 8 0 1 1 19 11 Z M 21 21 L 16.7 16.7"",
+ IconName.Search => ""M 19 11 C 19 15.418 15.418 19 11 19 C 6.582 19 3 15.418 3 11 C 3 6.582 6.582 3 11 3 C 15.418 3 19 6.582 19 11 Z M 21 21 L 16.7 16.7"",
IconName.Filter => ""M 22 3 L 2 3 L 10 12.46 L 10 19 L 14 21 L 14 12.46 L 22 3 Z"",
- IconName.Settings => ""M 12.22 2 L 11.78 2 A 2 2 0 0 0 9.78 4 L 9.78 4.18 A 2 2 0 0 1 8.78 5.91 L 8.35 6.16 A 2 2 0 0 1 6.35 6.16 L 6.2 6.08 A 2 2 0 0 0 3.47 6.81 L 3.25 7.19 A 2 2 0 0 0 3.98 9.92 L 4.13 10.02 A 2 2 0 0 1 5.13 11.74 L 5.13 12.25 A 2 2 0 0 1 4.13 13.99 L 3.98 14.08 A 2 2 0 0 0 3.25 16.81 L 3.47 17.19 A 2 2 0 0 0 6.2 17.92 L 6.35 17.84 A 2 2 0 0 1 8.35 17.84 L 8.78 18.09 A 2 2 0 0 1 9.78 19.82 L 9.78 20 A 2 2 0 0 0 11.78 22 L 12.22 22 A 2 2 0 0 0 14.22 20 L 14.22 19.82 A 2 2 0 0 1 15.22 18.09 L 15.65 17.84 A 2 2 0 0 1 17.65 17.84 L 17.8 17.92 A 2 2 0 0 0 20.53 17.19 L 20.75 16.8 A 2 2 0 0 0 20.02 14.07 L 19.87 13.99 A 2 2 0 0 1 18.87 12.25 L 18.87 11.75 A 2 2 0 0 1 19.87 10.01 L 20.02 9.92 A 2 2 0 0 0 20.75 7.19 L 20.53 6.81 A 2 2 0 0 0 17.8 6.08 L 17.65 6.16 A 2 2 0 0 1 15.65 6.16 L 15.22 5.91 A 2 2 0 0 1 14.22 4.18 L 14.22 4 A 2 2 0 0 0 12.22 2 Z M 15 12 A 3 3 0 1 1 9 12 A 3 3 0 1 1 15 12 Z"",
+ IconName.Settings => ""M 12.22 2 L 11.78 2 C 10.675 2 9.78 2.895 9.78 4 L 9.78 4.18 C 9.779 4.894 9.398 5.553 8.78 5.91 L 8.35 6.16 C 7.731 6.517 6.969 6.517 6.35 6.16 L 6.2 6.08 C 5.244 5.529 4.023 5.855 3.47 6.81 L 3.25 7.19 C 2.699 8.146 3.025 9.367 3.98 9.92 L 4.13 10.02 C 4.745 10.375 5.126 11.03 5.13 11.74 L 5.13 12.25 C 5.133 12.967 4.751 13.631 4.13 13.99 L 3.98 14.08 C 3.025 14.633 2.699 15.854 3.25 16.81 L 3.47 17.19 C 4.023 18.145 5.244 18.471 6.2 17.92 L 6.35 17.84 C 6.969 17.483 7.731 17.483 8.35 17.84 L 8.78 18.09 C 9.398 18.447 9.779 19.106 9.78 19.82 L 9.78 20 C 9.78 21.105 10.675 22 11.78 22 L 12.22 22 C 13.325 22 14.22 21.105 14.22 20 L 14.22 19.82 C 14.221 19.106 14.602 18.447 15.22 18.09 L 15.65 17.84 C 16.269 17.483 17.031 17.483 17.65 17.84 L 17.8 17.92 C 18.756 18.471 19.977 18.145 20.53 17.19 L 20.75 16.8 C 21.301 15.844 20.975 14.623 20.02 14.07 L 19.87 13.99 C 19.249 13.631 18.867 12.967 18.87 12.25 L 18.87 11.75 C 18.867 11.033 19.249 10.369 19.87 10.01 L 20.02 9.92 C 20.975 9.367 21.301 8.146 20.75 7.19 L 20.53 6.81 C 19.977 5.855 18.756 5.529 17.8 6.08 L 17.65 6.16 C 17.031 6.517 16.269 6.517 15.65 6.16 L 15.22 5.91 C 14.602 5.553 14.221 4.894 14.22 4.18 L 14.22 4 C 14.22 2.895 13.325 2 12.22 2 Z 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.SlidersHorizontal => ""M 21 4 L 14 4 M 10 4 L 3 4 M 21 12 L 12 12 M 8 12 L 3 12 M 21 20 L 16 20 M 12 20 L 3 20 M 14 2 L 14 6 M 8 10 L 8 14 M 16 18 L 16 22"",
- IconName.Pencil => ""M 21.174 6.812 A 1 1 0 0 0 17.188 2.825 L 3.842 16.174 A 2 2 0 0 0 3.342 17.004 L 2.021 21.356 A 0.5 0.5 0 0 0 2.644 21.978 L 6.997 20.658 A 2 2 0 0 0 7.827 20.161 Z M 15 5 L 19 9"",
+ IconName.Pencil => ""M 21.174 6.812 C 22.275 5.711 22.275 3.926 21.174 2.826 C 20.074 1.725 18.289 1.724 17.188 2.825 L 3.842 16.174 C 3.61 16.406 3.438 16.691 3.342 17.004 L 2.021 21.356 C 1.968 21.532 2.017 21.723 2.147 21.853 C 2.277 21.983 2.468 22.031 2.644 21.978 L 6.997 20.658 C 7.31 20.563 7.595 20.392 7.827 20.161 Z M 15 5 L 19 9"",
IconName.Trash => ""M 3 6 L 21 6 M 19 6 L 19 20 C 19 21 18 22 17 22 L 7 22 C 6 22 5 21 5 20 L 5 6 M 8 6 L 8 4 C 8 3 9 2 10 2 L 14 2 C 15 2 16 3 16 4 L 16 6"",
IconName.Trash2 => ""M 3 6 L 21 6 M 19 6 L 19 20 C 19 21 18 22 17 22 L 7 22 C 6 22 5 21 5 20 L 5 6 M 8 6 L 8 4 C 8 3 9 2 10 2 L 14 2 C 15 2 16 3 16 4 L 16 6 M 10 11 L 10 17 M 14 11 L 14 17"",
- IconName.Copy => ""M 10 8 L 20 8 A 2 2 0 0 1 22 10 L 22 20 A 2 2 0 0 1 20 22 L 10 22 A 2 2 0 0 1 8 20 L 8 10 A 2 2 0 0 1 10 8 Z M 4 16 C 2.9 16 2 15.1 2 14 L 2 4 C 2 2.9 2.9 2 4 2 L 14 2 C 15.1 2 16 2.9 16 4"",
- IconName.Clipboard => ""M 9 2 L 15 2 A 1 1 0 0 1 16 3 L 16 5 A 1 1 0 0 1 15 6 L 9 6 A 1 1 0 0 1 8 5 L 8 3 A 1 1 0 0 1 9 2 Z M 16 4 L 18 4 A 2 2 0 0 1 20 6 L 20 20 A 2 2 0 0 1 18 22 L 6 22 A 2 2 0 0 1 4 20 L 4 6 A 2 2 0 0 1 6 4 L 8 4"",
- IconName.Save => ""M 15.2 3 A 2 2 0 0 1 16.6 3.6 L 20.4 7.4 A 2 2 0 0 1 21 8.8 L 21 19 A 2 2 0 0 1 19 21 L 5 21 A 2 2 0 0 1 3 19 L 3 5 A 2 2 0 0 1 5 3 Z M 17 21 L 17 14 A 1 1 0 0 0 16 13 L 8 13 A 1 1 0 0 0 7 14 L 7 21 M 7 3 L 7 7 A 1 1 0 0 0 8 8 L 15 8"",
- IconName.Download => ""M 21 15 L 21 19 A 2 2 0 0 1 19 21 L 5 21 A 2 2 0 0 1 3 19 L 3 15 M 7 10 L 12 15 L 17 10 M 12 15 L 12 3"",
- IconName.Upload => ""M 21 15 L 21 19 A 2 2 0 0 1 19 21 L 5 21 A 2 2 0 0 1 3 19 L 3 15 M 17 8 L 12 3 L 7 8 M 12 3 L 12 15"",
- IconName.Share => ""M 4 12 L 4 20 A 2 2 0 0 0 6 22 L 18 22 A 2 2 0 0 0 20 20 L 20 12 M 16 6 L 12 2 L 8 6 M 12 2 L 12 15"",
- IconName.Share2 => ""M 21 5 A 3 3 0 1 1 15 5 A 3 3 0 1 1 21 5 Z M 9 12 A 3 3 0 1 1 3 12 A 3 3 0 1 1 9 12 Z M 21 19 A 3 3 0 1 1 15 19 A 3 3 0 1 1 21 19 Z M 8.59 13.51 L 15.42 17.49 M 15.41 6.51 L 8.59 10.49"",
- IconName.ExternalLink => ""M 15 3 L 21 3 L 21 9 M 10 14 L 21 3 M 18 13 L 18 19 A 2 2 0 0 1 16 21 L 5 21 A 2 2 0 0 1 3 19 L 3 8 A 2 2 0 0 1 5 6 L 11 6"",
- IconName.Link => ""M 10 13 A 5 5 0 0 0 17.54 13.54 L 20.54 10.54 A 5 5 0 0 0 13.47 3.47 L 11.75 5.18 M 14 11 A 5 5 0 0 0 6.46 10.46 L 3.46 13.46 A 5 5 0 0 0 10.53 20.53 L 12.24 18.82"",
- IconName.RefreshCw => ""M 3 12 A 9 9 0 0 1 12 3 A 9.75 9.75 0 0 1 18.74 5.74 L 21 8 M 21 3 L 21 8 L 16 8 M 21 12 A 9 9 0 0 1 12 21 A 9.75 9.75 0 0 1 5.26 18.26 L 3 16 M 8 16 L 3 16 L 3 21"",
- IconName.RotateCcw => ""M 3 12 A 9 9 0 1 0 12 3 A 9.75 9.75 0 0 0 5.26 5.74 L 3 8 M 3 3 L 3 8 L 8 8"",
- IconName.Undo2 => ""M 9 14 L 4 9 L 9 4 M 4 9 L 14.5 9 A 5.5 5.5 0 0 1 20 14.5 A 5.5 5.5 0 0 1 14.5 20 L 11 20"",
- IconName.Redo2 => ""M 15 14 L 20 9 L 15 4 M 20 9 L 9.5 9 A 5.5 5.5 0 0 0 4 14.5 A 5.5 5.5 0 0 0 9.5 20 L 13 20"",
- IconName.LogIn => ""M 15 3 L 19 3 A 2 2 0 0 1 21 5 L 21 19 A 2 2 0 0 1 19 21 L 15 21 M 10 17 L 15 12 L 10 7 M 15 12 L 3 12"",
- IconName.LogOut => ""M 9 21 L 5 21 A 2 2 0 0 1 3 19 L 3 5 A 2 2 0 0 1 5 3 L 9 3 M 16 17 L 21 12 L 16 7 M 21 12 L 9 12"",
- IconName.Send => ""M 14.536 21.686 A 0.5 0.5 0 0 0 15.473 21.662 L 21.973 2.662 A 0.496 0.496 0 0 0 21.338 2.027 L 2.338 8.527 A 0.5 0.5 0 0 0 2.314 9.464 L 10.244 12.644 A 2 2 0 0 1 11.356 13.754 Z M 21.854 2.147 L 10.914 13.086"",
- IconName.Paperclip => ""M 13.234 20.252 L 21 12.3 M 16 6 L 7.586 14.586 A 2 2 0 0 0 7.586 17.414 A 2 2 0 0 0 10.414 17.414 L 18.828 8.828 A 4 4 0 0 0 18.828 3.172 A 4 4 0 0 0 13.172 3.172 L 4.757 11.757 A 6 6 0 1 0 13.243 20.243"",
+ IconName.Copy => ""M 10 8 L 20 8 C 21.105 8 22 8.895 22 10 L 22 20 C 22 21.105 21.105 22 20 22 L 10 22 C 8.895 22 8 21.105 8 20 L 8 10 C 8 8.895 8.895 8 10 8 Z M 4 16 C 2.9 16 2 15.1 2 14 L 2 4 C 2 2.9 2.9 2 4 2 L 14 2 C 15.1 2 16 2.9 16 4"",
+ IconName.Clipboard => ""M 9 2 L 15 2 C 15.552 2 16 2.448 16 3 L 16 5 C 16 5.552 15.552 6 15 6 L 9 6 C 8.448 6 8 5.552 8 5 L 8 3 C 8 2.448 8.448 2 9 2 Z M 16 4 L 18 4 C 19.105 4 20 4.895 20 6 L 20 20 C 20 21.105 19.105 22 18 22 L 6 22 C 4.895 22 4 21.105 4 20 L 4 6 C 4 4.895 4.895 4 6 4 L 8 4"",
+ IconName.Save => ""M 15.2 3 C 15.728 3.008 16.231 3.223 16.6 3.6 L 20.4 7.4 C 20.777 7.769 20.992 8.272 21 8.8 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 17 21 L 17 14 C 17 13.448 16.552 13 16 13 L 8 13 C 7.448 13 7 13.448 7 14 L 7 21 M 7 3 L 7 7 C 7 7.552 7.448 8 8 8 L 15 8"",
+ IconName.Download => ""M 21 15 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 15 M 7 10 L 12 15 L 17 10 M 12 15 L 12 3"",
+ IconName.Upload => ""M 21 15 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 15 M 17 8 L 12 3 L 7 8 M 12 3 L 12 15"",
+ IconName.Share => ""M 4 12 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 12 M 16 6 L 12 2 L 8 6 M 12 2 L 12 15"",
+ IconName.Share2 => ""M 21 5 C 21 6.657 19.657 8 18 8 C 16.343 8 15 6.657 15 5 C 15 3.343 16.343 2 18 2 C 19.657 2 21 3.343 21 5 Z M 9 12 C 9 13.657 7.657 15 6 15 C 4.343 15 3 13.657 3 12 C 3 10.343 4.343 9 6 9 C 7.657 9 9 10.343 9 12 Z M 21 19 C 21 20.657 19.657 22 18 22 C 16.343 22 15 20.657 15 19 C 15 17.343 16.343 16 18 16 C 19.657 16 21 17.343 21 19 Z M 8.59 13.51 L 15.42 17.49 M 15.41 6.51 L 8.59 10.49"",
+ IconName.ExternalLink => ""M 15 3 L 21 3 L 21 9 M 10 14 L 21 3 M 18 13 L 18 19 C 18 20.105 17.105 21 16 21 L 5 21 C 3.895 21 3 20.105 3 19 L 3 8 C 3 6.895 3.895 6 5 6 L 11 6"",
+ IconName.Link => ""M 10 13 C 10.869 14.162 12.2 14.889 13.647 14.992 C 15.094 15.096 16.514 14.566 17.54 13.54 L 20.54 10.54 C 22.435 8.578 22.408 5.46 20.479 3.531 C 18.55 1.602 15.432 1.575 13.47 3.47 L 11.75 5.18 M 14 11 C 13.131 9.838 11.8 9.111 10.353 9.008 C 8.906 8.904 7.486 9.434 6.46 10.46 L 3.46 13.46 C 1.565 15.422 1.592 18.54 3.521 20.469 C 5.45 22.398 8.568 22.425 10.53 20.53 L 12.24 18.82"",
+ IconName.RefreshCw => ""M 3 12 C 3 7.029 7.029 3 12 3 C 14.516 3.009 16.931 3.991 18.74 5.74 L 21 8 M 21 3 L 21 8 L 16 8 M 21 12 C 21 16.971 16.971 21 12 21 C 9.484 20.991 7.069 20.009 5.26 18.26 L 3 16 M 8 16 L 3 16 L 3 21"",
+ IconName.RotateCcw => ""M 3 12 C 3 16.971 7.029 21 12 21 C 16.971 21 21 16.971 21 12 C 21 7.029 16.971 3 12 3 C 9.484 3.009 7.069 3.991 5.26 5.74 L 3 8 M 3 3 L 3 8 L 8 8"",
+ IconName.Undo2 => ""M 9 14 L 4 9 L 9 4 M 4 9 L 14.5 9 C 17.538 9 20 11.462 20 14.5 C 20 17.538 17.538 20 14.5 20 L 11 20"",
+ IconName.Redo2 => ""M 15 14 L 20 9 L 15 4 M 20 9 L 9.5 9 C 6.462 9 4 11.462 4 14.5 C 4 17.538 6.462 20 9.5 20 L 13 20"",
+ IconName.LogIn => ""M 15 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 15 21 M 10 17 L 15 12 L 10 7 M 15 12 L 3 12"",
+ IconName.LogOut => ""M 9 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 L 9 3 M 16 17 L 21 12 L 16 7 M 21 12 L 9 12"",
+ IconName.Send => ""M 14.536 21.686 C 14.614 21.88 14.804 22.005 15.013 22 C 15.222 21.994 15.405 21.86 15.473 21.662 L 21.973 2.662 C 22.038 2.481 21.993 2.279 21.857 2.143 C 21.721 2.007 21.519 1.962 21.338 2.027 L 2.338 8.527 C 2.14 8.595 2.006 8.778 2 8.987 C 1.995 9.196 2.12 9.386 2.314 9.464 L 10.244 12.644 C 10.751 12.847 11.152 13.248 11.356 13.754 Z M 21.854 2.147 L 10.914 13.086"",
+ IconName.Paperclip => ""M 13.234 20.252 L 21 12.3 M 16 6 L 7.586 14.586 C 6.805 15.367 6.805 16.633 7.586 17.414 C 8.367 18.195 9.633 18.195 10.414 17.414 L 18.828 8.828 C 20.39 7.266 20.39 4.734 18.828 3.172 C 17.266 1.61 14.734 1.61 13.172 3.172 L 4.757 11.757 C 2.414 14.1 2.414 17.9 4.757 20.243 C 7.1 22.586 10.9 22.586 13.243 20.243"",
IconName.ChevronDown => ""M 6 9 L 12 15 L 18 9"",
IconName.ChevronUp => ""M 18 15 L 12 9 L 6 15"",
IconName.ChevronLeft => ""M 15 18 L 9 12 L 15 6"",
@@ -187,75 +187,75 @@ public static class IconPaths
IconName.ArrowDown => ""M 12 5 L 12 19 M 19 12 L 12 19 L 5 12"",
IconName.ArrowUpRight => ""M 7 7 L 17 7 L 17 17 M 7 17 L 17 7"",
IconName.Menu => ""M 4 12 L 20 12 M 4 6 L 20 6 M 4 18 L 20 18"",
- IconName.Ellipsis => ""M 13 12 A 1 1 0 1 1 11 12 A 1 1 0 1 1 13 12 Z M 20 12 A 1 1 0 1 1 18 12 A 1 1 0 1 1 20 12 Z M 6 12 A 1 1 0 1 1 4 12 A 1 1 0 1 1 6 12 Z"",
- IconName.EllipsisVertical => ""M 13 12 A 1 1 0 1 1 11 12 A 1 1 0 1 1 13 12 Z M 13 5 A 1 1 0 1 1 11 5 A 1 1 0 1 1 13 5 Z M 13 19 A 1 1 0 1 1 11 19 A 1 1 0 1 1 13 19 Z"",
- IconName.GripVertical => ""M 10 12 A 1 1 0 1 1 8 12 A 1 1 0 1 1 10 12 Z M 10 5 A 1 1 0 1 1 8 5 A 1 1 0 1 1 10 5 Z M 10 19 A 1 1 0 1 1 8 19 A 1 1 0 1 1 10 19 Z M 16 12 A 1 1 0 1 1 14 12 A 1 1 0 1 1 16 12 Z M 16 5 A 1 1 0 1 1 14 5 A 1 1 0 1 1 16 5 Z M 16 19 A 1 1 0 1 1 14 19 A 1 1 0 1 1 16 19 Z"",
- IconName.House => ""M 15 21 L 15 13 A 1 1 0 0 0 14 12 L 10 12 A 1 1 0 0 0 9 13 L 9 21 M 3 10 A 2 2 0 0 1 3.709 8.472 L 10.709 2.473 A 2 2 0 0 1 13.291 2.473 L 20.291 8.472 A 2 2 0 0 1 21 10 L 21 19 A 2 2 0 0 1 19 21 L 5 21 A 2 2 0 0 1 3 19 Z"",
- IconName.LayoutDashboard => ""M 4 3 L 9 3 A 1 1 0 0 1 10 4 L 10 11 A 1 1 0 0 1 9 12 L 4 12 A 1 1 0 0 1 3 11 L 3 4 A 1 1 0 0 1 4 3 Z M 15 3 L 20 3 A 1 1 0 0 1 21 4 L 21 7 A 1 1 0 0 1 20 8 L 15 8 A 1 1 0 0 1 14 7 L 14 4 A 1 1 0 0 1 15 3 Z M 15 12 L 20 12 A 1 1 0 0 1 21 13 L 21 20 A 1 1 0 0 1 20 21 L 15 21 A 1 1 0 0 1 14 20 L 14 13 A 1 1 0 0 1 15 12 Z M 4 16 L 9 16 A 1 1 0 0 1 10 17 L 10 20 A 1 1 0 0 1 9 21 L 4 21 A 1 1 0 0 1 3 20 L 3 17 A 1 1 0 0 1 4 16 Z"",
- IconName.PanelLeft => ""M 5 3 L 19 3 A 2 2 0 0 1 21 5 L 21 19 A 2 2 0 0 1 19 21 L 5 21 A 2 2 0 0 1 3 19 L 3 5 A 2 2 0 0 1 5 3 Z M 9 3 L 9 21"",
- IconName.Info => ""M 22 12 A 10 10 0 1 1 2 12 A 10 10 0 1 1 22 12 Z M 12 16 L 12 12 M 12 8 L 12.01 8"",
- IconName.CircleAlert => ""M 22 12 A 10 10 0 1 1 2 12 A 10 10 0 1 1 22 12 Z M 12 8 L 12 12 M 12 16 L 12.01 16"",
- IconName.CircleCheck => ""M 22 12 A 10 10 0 1 1 2 12 A 10 10 0 1 1 22 12 Z M 9 12 L 11 14 L 15 10"",
- IconName.CircleX => ""M 22 12 A 10 10 0 1 1 2 12 A 10 10 0 1 1 22 12 Z M 15 9 L 9 15 M 9 9 L 15 15"",
- IconName.CircleHelp => ""M 22 12 A 10 10 0 1 1 2 12 A 10 10 0 1 1 22 12 Z M 9.09 9 A 3 3 0 0 1 14.92 10 C 14.92 12 11.92 13 11.92 13 M 12 17 L 12.01 17"",
- IconName.TriangleAlert => ""M 21.73 18 L 13.73 4 A 2 2 0 0 0 10.25 4 L 2.25 18 A 2 2 0 0 0 4 21 L 20 21 A 2 2 0 0 0 21.73 18 M 12 9 L 12 13 M 12 17 L 12.01 17"",
- IconName.Ban => ""M 22 12 A 10 10 0 1 1 2 12 A 10 10 0 1 1 22 12 Z M 4.9 4.9 L 19.1 19.1"",
+ IconName.Ellipsis => ""M 13 12 C 13 12.552 12.552 13 12 13 C 11.448 13 11 12.552 11 12 C 11 11.448 11.448 11 12 11 C 12.552 11 13 11.448 13 12 Z M 20 12 C 20 12.552 19.552 13 19 13 C 18.448 13 18 12.552 18 12 C 18 11.448 18.448 11 19 11 C 19.552 11 20 11.448 20 12 Z M 6 12 C 6 12.552 5.552 13 5 13 C 4.448 13 4 12.552 4 12 C 4 11.448 4.448 11 5 11 C 5.552 11 6 11.448 6 12 Z"",
+ IconName.EllipsisVertical => ""M 13 12 C 13 12.552 12.552 13 12 13 C 11.448 13 11 12.552 11 12 C 11 11.448 11.448 11 12 11 C 12.552 11 13 11.448 13 12 Z M 13 5 C 13 5.552 12.552 6 12 6 C 11.448 6 11 5.552 11 5 C 11 4.448 11.448 4 12 4 C 12.552 4 13 4.448 13 5 Z M 13 19 C 13 19.552 12.552 20 12 20 C 11.448 20 11 19.552 11 19 C 11 18.448 11.448 18 12 18 C 12.552 18 13 18.448 13 19 Z"",
+ IconName.GripVertical => ""M 10 12 C 10 12.552 9.552 13 9 13 C 8.448 13 8 12.552 8 12 C 8 11.448 8.448 11 9 11 C 9.552 11 10 11.448 10 12 Z M 10 5 C 10 5.552 9.552 6 9 6 C 8.448 6 8 5.552 8 5 C 8 4.448 8.448 4 9 4 C 9.552 4 10 4.448 10 5 Z M 10 19 C 10 19.552 9.552 20 9 20 C 8.448 20 8 19.552 8 19 C 8 18.448 8.448 18 9 18 C 9.552 18 10 18.448 10 19 Z M 16 12 C 16 12.552 15.552 13 15 13 C 14.448 13 14 12.552 14 12 C 14 11.448 14.448 11 15 11 C 15.552 11 16 11.448 16 12 Z M 16 5 C 16 5.552 15.552 6 15 6 C 14.448 6 14 5.552 14 5 C 14 4.448 14.448 4 15 4 C 15.552 4 16 4.448 16 5 Z M 16 19 C 16 19.552 15.552 20 15 20 C 14.448 20 14 19.552 14 19 C 14 18.448 14.448 18 15 18 C 15.552 18 16 18.448 16 19 Z"",
+ IconName.House => ""M 15 21 L 15 13 C 15 12.448 14.552 12 14 12 L 10 12 C 9.448 12 9 12.448 9 13 L 9 21 M 3 10 C 3 9.411 3.259 8.852 3.709 8.472 L 10.709 2.473 C 11.454 1.843 12.546 1.843 13.291 2.473 L 20.291 8.472 C 20.741 8.852 21 9.411 21 10 L 21 19 C 21 20.105 20.105 21 19 21 L 5 21 C 3.895 21 3 20.105 3 19 Z"",
+ IconName.LayoutDashboard => ""M 4 3 L 9 3 C 9.552 3 10 3.448 10 4 L 10 11 C 10 11.552 9.552 12 9 12 L 4 12 C 3.448 12 3 11.552 3 11 L 3 4 C 3 3.448 3.448 3 4 3 Z M 15 3 L 20 3 C 20.552 3 21 3.448 21 4 L 21 7 C 21 7.552 20.552 8 20 8 L 15 8 C 14.448 8 14 7.552 14 7 L 14 4 C 14 3.448 14.448 3 15 3 Z M 15 12 L 20 12 C 20.552 12 21 12.448 21 13 L 21 20 C 21 20.552 20.552 21 20 21 L 15 21 C 14.448 21 14 20.552 14 20 L 14 13 C 14 12.448 14.448 12 15 12 Z M 4 16 L 9 16 C 9.552 16 10 16.448 10 17 L 10 20 C 10 20.552 9.552 21 9 21 L 4 21 C 3.448 21 3 20.552 3 20 L 3 17 C 3 16.448 3.448 16 4 16 Z"",
+ IconName.PanelLeft => ""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 9 3 L 9 21"",
+ IconName.Info => ""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 16 L 12 12 M 12 8 L 12.01 8"",
+ IconName.CircleAlert => ""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 8 L 12 12 M 12 16 L 12.01 16"",
+ IconName.CircleCheck => ""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 9 12 L 11 14 L 15 10"",
+ IconName.CircleX => ""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 15 9 L 9 15 M 9 9 L 15 15"",
+ IconName.CircleHelp => ""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 9.09 9 C 9.575 7.62 10.985 6.791 12.427 7.039 C 13.869 7.286 14.922 8.537 14.92 10 C 14.92 12 11.92 13 11.92 13 M 12 17 L 12.01 17"",
+ IconName.TriangleAlert => ""M 21.73 18 L 13.73 4 C 13.375 3.373 12.71 2.986 11.99 2.986 C 11.27 2.986 10.605 3.373 10.25 4 L 2.25 18 C 1.891 18.622 1.893 19.388 2.254 20.008 C 2.616 20.628 3.282 21.006 4 21 L 20 21 C 20.714 20.999 21.374 20.618 21.731 19.999 C 22.087 19.38 22.087 18.618 21.73 18 M 12 9 L 12 13 M 12 17 L 12.01 17"",
+ IconName.Ban => ""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 4.9 4.9 L 19.1 19.1"",
IconName.Loader => ""M 12 2 L 12 6 M 16.2 7.8 L 19.1 4.9 M 18 12 L 22 12 M 16.2 16.2 L 19.1 19.1 M 12 18 L 12 22 M 4.9 19.1 L 7.8 16.2 M 2 12 L 6 12 M 4.9 4.9 L 7.8 7.8"",
- IconName.LoaderCircle => ""M 21 12 A 9 9 0 1 1 14.781 3.44"",
- IconName.Bell => ""M 10.268 21 A 2 2 0 0 0 13.732 21 M 3.262 15.326 A 1 1 0 0 0 4 17 L 20 17 A 1 1 0 0 0 20.74 15.327 C 19.41 13.956 18 12.499 18 8 A 6 6 0 0 0 6 8 C 6 12.499 4.589 13.956 3.262 15.326"",
- IconName.Circle => ""M 22 12 A 10 10 0 1 1 2 12 A 10 10 0 1 1 22 12 Z"",
- IconName.CirclePlus => ""M 22 12 A 10 10 0 1 1 2 12 A 10 10 0 1 1 22 12 Z M 8 12 L 16 12 M 12 8 L 12 16"",
- IconName.Dot => ""M 13.1 12.1 A 1 1 0 1 1 11.1 12.1 A 1 1 0 1 1 13.1 12.1 Z"",
- IconName.User => ""M 19 21 L 19 19 A 4 4 0 0 0 15 15 L 9 15 A 4 4 0 0 0 5 19 L 5 21 M 16 7 A 4 4 0 1 1 8 7 A 4 4 0 1 1 16 7 Z"",
- IconName.Users => ""M 16 21 L 16 19 A 4 4 0 0 0 12 15 L 6 15 A 4 4 0 0 0 2 19 L 2 21 M 13 7 A 4 4 0 1 1 5 7 A 4 4 0 1 1 13 7 Z M 22 21 L 22 19 A 4 4 0 0 0 19 15.13 M 16 3.13 A 4 4 0 0 1 16 10.88"",
- IconName.UserPlus => ""M 16 21 L 16 19 A 4 4 0 0 0 12 15 L 6 15 A 4 4 0 0 0 2 19 L 2 21 M 13 7 A 4 4 0 1 1 5 7 A 4 4 0 1 1 13 7 Z M 19 8 L 19 14 M 22 11 L 16 11"",
- IconName.Lock => ""M 5 11 L 19 11 A 2 2 0 0 1 21 13 L 21 20 A 2 2 0 0 1 19 22 L 5 22 A 2 2 0 0 1 3 20 L 3 13 A 2 2 0 0 1 5 11 Z M 7 11 L 7 7 A 5 5 0 0 1 17 7 L 17 11"",
- IconName.LockOpen => ""M 5 11 L 19 11 A 2 2 0 0 1 21 13 L 21 20 A 2 2 0 0 1 19 22 L 5 22 A 2 2 0 0 1 3 20 L 3 13 A 2 2 0 0 1 5 11 Z M 7 11 L 7 7 A 5 5 0 0 1 16.9 6"",
- IconName.Key => ""M 15.5 7.5 L 17.8 9.8 A 1 1 0 0 0 19.2 9.8 L 21.3 7.7 A 1 1 0 0 0 21.3 6.3 L 19 4 M 21 2 L 11.4 11.6 M 13 15.5 A 5.5 5.5 0 1 1 2 15.5 A 5.5 5.5 0 1 1 13 15.5 Z"",
- IconName.Shield => ""M 20 13 C 20 18 16.5 20.5 12.34 21.95 A 1 1 0 0 1 11.67 21.94 C 7.5 20.5 4 18 4 13 L 4 6 A 1 1 0 0 1 5 5 C 7 5 9.5 3.8 11.24 2.28 A 1.17 1.17 0 0 1 12.76 2.28 C 14.51 3.81 17 5 19 5 A 1 1 0 0 1 20 6 Z"",
- IconName.Eye => ""M 2.062 12.348 A 1 1 0 0 1 2.062 11.652 A 10.75 10.75 0 0 1 21.938 11.652 A 1 1 0 0 1 21.938 12.348 A 10.75 10.75 0 0 1 2.062 12.348 M 15 12 A 3 3 0 1 1 9 12 A 3 3 0 1 1 15 12 Z"",
- IconName.EyeOff => ""M 10.733 5.076 A 10.744 10.744 0 0 1 21.938 11.651 A 1 1 0 0 1 21.938 12.347 A 10.747 10.747 0 0 1 20.494 14.837 M 14.084 14.158 A 3 3 0 0 1 9.842 9.916 M 17.479 17.499 A 10.75 10.75 0 0 1 2.062 12.348 A 1 1 0 0 1 2.062 11.652 A 10.75 10.75 0 0 1 6.508 6.509 M 2 2 L 22 22"",
- IconName.Mail => ""M 4 4 L 20 4 A 2 2 0 0 1 22 6 L 22 18 A 2 2 0 0 1 20 20 L 4 20 A 2 2 0 0 1 2 18 L 2 6 A 2 2 0 0 1 4 4 Z M 22 7 L 13.03 12.7 A 1.94 1.94 0 0 1 10.97 12.7 L 2 7"",
- IconName.Inbox => ""M 22 12 L 16 12 L 14 15 L 10 15 L 8 12 L 2 12 M 5.45 5.11 L 2 12 L 2 18 A 2 2 0 0 0 4 20 L 20 20 A 2 2 0 0 0 22 18 L 22 12 L 18.55 5.11 A 2 2 0 0 0 16.76 4 L 7.24 4 A 2 2 0 0 0 5.45 5.11 Z"",
- IconName.Archive => ""M 3 3 L 21 3 A 1 1 0 0 1 22 4 L 22 7 A 1 1 0 0 1 21 8 L 3 8 A 1 1 0 0 1 2 7 L 2 4 A 1 1 0 0 1 3 3 Z M 4 8 L 4 19 A 2 2 0 0 0 6 21 L 18 21 A 2 2 0 0 0 20 19 L 20 8 M 10 12 L 14 12"",
- IconName.MessageCircle => ""M 7.9 20 A 9 9 0 1 0 4 16.1 L 2 22 Z"",
- IconName.Phone => ""M 22 16.92 L 22 19.92 A 2 2 0 0 1 19.82 21.92 A 19.79 19.79 0 0 1 11.19 18.85 A 19.5 19.5 0 0 1 5.19 12.85 A 19.79 19.79 0 0 1 2.12 4.18 A 2 2 0 0 1 4.11 2 L 7.11 2 A 2 2 0 0 1 9.11 3.72 A 12.84 12.84 0 0 0 9.81 6.53 A 2 2 0 0 1 9.36 8.64 L 8.09 9.91 A 16 16 0 0 0 14.09 15.91 L 15.36 14.64 A 2 2 0 0 1 17.47 14.19 A 12.84 12.84 0 0 0 20.28 14.89 A 2 2 0 0 1 22 16.92 Z"",
- IconName.Calendar => ""M 8 2 L 8 6 M 16 2 L 16 6 M 5 4 L 19 4 A 2 2 0 0 1 21 6 L 21 20 A 2 2 0 0 1 19 22 L 5 22 A 2 2 0 0 1 3 20 L 3 6 A 2 2 0 0 1 5 4 Z M 3 10 L 21 10"",
- IconName.Clock => ""M 22 12 A 10 10 0 1 1 2 12 A 10 10 0 1 1 22 12 Z M 12 6 L 12 12 L 16 14"",
- IconName.Globe => ""M 22 12 A 10 10 0 1 1 2 12 A 10 10 0 1 1 22 12 Z M 12 2 A 14.5 14.5 0 0 0 12 22 A 14.5 14.5 0 0 0 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 A 1 1 0 0 1 11.399 21.799 C 9.539 20.193 4 14.993 4 10 A 8 8 0 0 1 20 10 M 15 10 A 3 3 0 1 1 9 10 A 3 3 0 1 1 15 10 Z"",
- IconName.Image => ""M 5 3 L 19 3 A 2 2 0 0 1 21 5 L 21 19 A 2 2 0 0 1 19 21 L 5 21 A 2 2 0 0 1 3 19 L 3 5 A 2 2 0 0 1 5 3 Z M 11 9 A 2 2 0 1 1 7 9 A 2 2 0 1 1 11 9 Z M 21 15 L 17.914 11.914 A 2 2 0 0 0 15.086 11.914 L 6 21"",
- IconName.File => ""M 15 2 L 6 2 A 2 2 0 0 0 4 4 L 4 20 A 2 2 0 0 0 6 22 L 18 22 A 2 2 0 0 0 20 20 L 20 7 Z M 14 2 L 14 6 A 2 2 0 0 0 16 8 L 20 8"",
- IconName.FileText => ""M 15 2 L 6 2 A 2 2 0 0 0 4 4 L 4 20 A 2 2 0 0 0 6 22 L 18 22 A 2 2 0 0 0 20 20 L 20 7 Z M 14 2 L 14 6 A 2 2 0 0 0 16 8 L 20 8 M 10 9 L 8 9 M 16 13 L 8 13 M 16 17 L 8 17"",
- IconName.Folder => ""M 20 20 A 2 2 0 0 0 22 18 L 22 8 A 2 2 0 0 0 20 6 L 12.1 6 A 2 2 0 0 1 10.41 5.1 L 9.6 3.9 A 2 2 0 0 0 7.93 3 L 4 3 A 2 2 0 0 0 2 5 L 2 18 A 2 2 0 0 0 4 20 Z"",
- IconName.Tag => ""M 12.586 2.586 A 2 2 0 0 0 11.172 2 L 4 2 A 2 2 0 0 0 2 4 L 2 11.172 A 2 2 0 0 0 2.586 12.586 L 11.29 21.29 A 2.426 2.426 0 0 0 14.71 21.29 L 21.29 14.71 A 2.426 2.426 0 0 0 21.29 11.29 Z M 8 7.5 A 0.5 0.5 0 1 1 7 7.5 A 0.5 0.5 0 1 1 8 7.5 Z"",
- IconName.Bookmark => ""M 19 21 L 12 17 L 5 21 L 5 5 A 2 2 0 0 1 7 3 L 17 3 A 2 2 0 0 1 19 5 L 19 21 Z"",
- IconName.Heart => ""M 19 14 C 20.49 12.54 22 10.79 22 8.5 A 5.5 5.5 0 0 0 16.5 3 C 14.74 3 13.5 3.5 12 5 C 10.5 3.5 9.26 3 7.5 3 A 5.5 5.5 0 0 0 2 8.5 C 2 10.8 3.5 12.55 5 14 L 12 21 Z"",
- IconName.Star => ""M 11.525 2.295 A 0.53 0.53 0 0 1 12.475 2.295 L 14.785 6.974 A 2.123 2.123 0 0 0 16.38 8.134 L 21.546 8.89 A 0.53 0.53 0 0 1 21.84 9.794 L 18.104 13.432 A 2.123 2.123 0 0 0 17.493 15.31 L 18.375 20.45 A 0.53 0.53 0 0 1 17.604 21.01 L 12.986 18.582 A 2.122 2.122 0 0 0 11.013 18.582 L 6.396 21.01 A 0.53 0.53 0 0 1 5.626 20.45 L 6.507 15.311 A 2.122 2.122 0 0 0 5.896 13.432 L 2.16 9.795 A 0.53 0.53 0 0 1 2.454 8.889 L 7.619 8.134 A 2.122 2.122 0 0 0 9.216 6.974 Z"",
- IconName.ThumbsUp => ""M 7 10 L 7 22 M 15 5.88 L 14 10 L 19.83 10 A 2 2 0 0 1 21.75 12.56 L 19.42 20.56 A 2 2 0 0 1 17.5 22 L 4 22 A 2 2 0 0 1 2 20 L 2 12 A 2 2 0 0 1 4 10 L 6.76 10 A 2 2 0 0 0 8.55 8.89 L 12 2 A 3.13 3.13 0 0 1 15 5.88 Z"",
- IconName.ShoppingCart => ""M 9 21 A 1 1 0 1 1 7 21 A 1 1 0 1 1 9 21 Z M 20 21 A 1 1 0 1 1 18 21 A 1 1 0 1 1 20 21 Z M 2.05 2.05 L 4.05 2.05 L 6.71 14.47 A 2 2 0 0 0 8.71 16.05 L 18.49 16.05 A 2 2 0 0 0 20.44 14.48 L 22.09 7.05 L 5.12 7.05"",
- IconName.CreditCard => ""M 4 5 L 20 5 A 2 2 0 0 1 22 7 L 22 17 A 2 2 0 0 1 20 19 L 4 19 A 2 2 0 0 1 2 17 L 2 7 A 2 2 0 0 1 4 5 Z M 2 10 L 22 10"",
- IconName.DollarSign => ""M 12 2 L 12 22 M 17 5 L 9.5 5 A 3.5 3.5 0 0 0 9.5 12 L 14.5 12 A 3.5 3.5 0 0 1 14.5 19 L 6 19"",
- IconName.Activity => ""M 22 12 L 19.52 12 A 2 2 0 0 0 17.59 13.46 L 15.24 21.82 A 0.25 0.25 0 0 1 14.76 21.82 L 9.24 2.18 A 0.25 0.25 0 0 0 8.76 2.18 L 6.41 10.54 A 2 2 0 0 1 4.49 12 L 2 12"",
+ IconName.LoaderCircle => ""M 21 12 C 21 15.899 18.489 19.354 14.781 20.559 C 11.073 21.764 7.01 20.444 4.719 17.29 C 2.427 14.135 2.427 9.864 4.719 6.709 C 7.011 3.555 11.073 2.235 14.781 3.44"",
+ IconName.Bell => ""M 10.268 21 C 10.625 21.619 11.286 22 12 22 C 12.714 22 13.375 21.619 13.732 21 M 3.262 15.326 C 2.995 15.619 2.926 16.041 3.086 16.403 C 3.245 16.766 3.604 17 4 17 L 20 17 C 20.396 17 20.755 16.767 20.915 16.405 C 21.075 16.042 21.006 15.62 20.74 15.327 C 19.41 13.956 18 12.499 18 8 C 18 4.686 15.314 2 12 2 C 8.686 2 6 4.686 6 8 C 6 12.499 4.589 13.956 3.262 15.326"",
+ IconName.Circle => ""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"",
+ IconName.CirclePlus => ""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 8 12 L 16 12 M 12 8 L 12 16"",
+ IconName.Dot => ""M 13.1 12.1 C 13.1 12.652 12.652 13.1 12.1 13.1 C 11.548 13.1 11.1 12.652 11.1 12.1 C 11.1 11.548 11.548 11.1 12.1 11.1 C 12.652 11.1 13.1 11.548 13.1 12.1 Z"",
+ IconName.User => ""M 19 21 L 19 19 C 19 16.791 17.209 15 15 15 L 9 15 C 6.791 15 5 16.791 5 19 L 5 21 M 16 7 C 16 9.209 14.209 11 12 11 C 9.791 11 8 9.209 8 7 C 8 4.791 9.791 3 12 3 C 14.209 3 16 4.791 16 7 Z"",
+ IconName.Users => ""M 16 21 L 16 19 C 16 16.791 14.209 15 12 15 L 6 15 C 3.791 15 2 16.791 2 19 L 2 21 M 13 7 C 13 9.209 11.209 11 9 11 C 6.791 11 5 9.209 5 7 C 5 4.791 6.791 3 9 3 C 11.209 3 13 4.791 13 7 Z M 22 21 L 22 19 C 21.999 17.177 20.765 15.586 19 15.13 M 16 3.13 C 17.77 3.583 19.008 5.178 19.008 7.005 C 19.008 8.832 17.77 10.427 16 10.88"",
+ IconName.UserPlus => ""M 16 21 L 16 19 C 16 16.791 14.209 15 12 15 L 6 15 C 3.791 15 2 16.791 2 19 L 2 21 M 13 7 C 13 9.209 11.209 11 9 11 C 6.791 11 5 9.209 5 7 C 5 4.791 6.791 3 9 3 C 11.209 3 13 4.791 13 7 Z M 19 8 L 19 14 M 22 11 L 16 11"",
+ 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.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"",
+ IconName.Mail => ""M 4 4 L 20 4 C 21.105 4 22 4.895 22 6 L 22 18 C 22 19.105 21.105 20 20 20 L 4 20 C 2.895 20 2 19.105 2 18 L 2 6 C 2 4.895 2.895 4 4 4 Z M 22 7 L 13.03 12.7 C 12.4 13.095 11.6 13.095 10.97 12.7 L 2 7"",
+ IconName.Inbox => ""M 22 12 L 16 12 L 14 15 L 10 15 L 8 12 L 2 12 M 5.45 5.11 L 2 12 L 2 18 C 2 19.105 2.895 20 4 20 L 20 20 C 21.105 20 22 19.105 22 18 L 22 12 L 18.55 5.11 C 18.212 4.43 17.519 4 16.76 4 L 7.24 4 C 6.481 4 5.788 4.43 5.45 5.11 Z"",
+ IconName.Archive => ""M 3 3 L 21 3 C 21.552 3 22 3.448 22 4 L 22 7 C 22 7.552 21.552 8 21 8 L 3 8 C 2.448 8 2 7.552 2 7 L 2 4 C 2 3.448 2.448 3 3 3 Z M 4 8 L 4 19 C 4 20.105 4.895 21 6 21 L 18 21 C 19.105 21 20 20.105 20 19 L 20 8 M 10 12 L 14 12"",
+ 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.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.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"",
+ IconName.Heart => ""M 19 14 C 20.49 12.54 22 10.79 22 8.5 C 22 5.462 19.538 3 16.5 3 C 14.74 3 13.5 3.5 12 5 C 10.5 3.5 9.26 3 7.5 3 C 4.462 3 2 5.462 2 8.5 C 2 10.8 3.5 12.55 5 14 L 12 21 Z"",
+ IconName.Star => ""M 11.525 2.295 C 11.614 2.114 11.798 2 12 2 C 12.202 2 12.386 2.114 12.475 2.295 L 14.785 6.974 C 15.094 7.599 15.69 8.033 16.38 8.134 L 21.546 8.89 C 21.746 8.919 21.912 9.059 21.974 9.251 C 22.036 9.442 21.984 9.653 21.84 9.794 L 18.104 13.432 C 17.604 13.919 17.375 14.622 17.493 15.31 L 18.375 20.45 C 18.41 20.65 18.329 20.852 18.165 20.971 C 18.001 21.09 17.783 21.105 17.604 21.01 L 12.986 18.582 C 12.368 18.258 11.631 18.258 11.013 18.582 L 6.396 21.01 C 6.217 21.105 6 21.089 5.836 20.97 C 5.673 20.851 5.591 20.649 5.626 20.45 L 6.507 15.311 C 6.625 14.622 6.397 13.92 5.896 13.432 L 2.16 9.795 C 2.014 9.654 1.962 9.443 2.024 9.25 C 2.087 9.058 2.253 8.917 2.454 8.889 L 7.619 8.134 C 8.31 8.034 8.907 7.6 9.216 6.974 Z"",
+ IconName.ThumbsUp => ""M 7 10 L 7 22 M 15 5.88 L 14 10 L 19.83 10 C 20.46 10 21.052 10.296 21.43 10.8 C 21.808 11.304 21.926 11.956 21.75 12.56 L 19.42 20.56 C 19.171 21.413 18.389 22 17.5 22 L 4 22 C 2.895 22 2 21.105 2 20 L 2 12 C 2 10.895 2.895 10 4 10 L 6.76 10 C 7.519 10 8.212 9.57 8.55 8.89 L 12 2 C 12.955 2.012 13.853 2.459 14.437 3.215 C 15.022 3.971 15.229 4.952 15 5.88 Z"",
+ IconName.ShoppingCart => ""M 9 21 C 9 21.552 8.552 22 8 22 C 7.448 22 7 21.552 7 21 C 7 20.448 7.448 20 8 20 C 8.552 20 9 20.448 9 21 Z M 20 21 C 20 21.552 19.552 22 19 22 C 18.448 22 18 21.552 18 21 C 18 20.448 18.448 20 19 20 C 19.552 20 20 20.448 20 21 Z M 2.05 2.05 L 4.05 2.05 L 6.71 14.47 C 6.911 15.408 7.751 16.071 8.71 16.05 L 18.49 16.05 C 19.428 16.048 20.238 15.396 20.44 14.48 L 22.09 7.05 L 5.12 7.05"",
+ IconName.CreditCard => ""M 4 5 L 20 5 C 21.105 5 22 5.895 22 7 L 22 17 C 22 18.105 21.105 19 20 19 L 4 19 C 2.895 19 2 18.105 2 17 L 2 7 C 2 5.895 2.895 5 4 5 Z M 2 10 L 22 10"",
+ IconName.DollarSign => ""M 12 2 L 12 22 M 17 5 L 9.5 5 C 7.567 5 6 6.567 6 8.5 C 6 10.433 7.567 12 9.5 12 L 14.5 12 C 16.433 12 18 13.567 18 15.5 C 18 17.433 16.433 19 14.5 19 L 6 19"",
+ IconName.Activity => ""M 22 12 L 19.52 12 C 18.622 11.998 17.833 12.595 17.59 13.46 L 15.24 21.82 C 15.209 21.927 15.111 22 15 22 C 14.889 22 14.791 21.927 14.76 21.82 L 9.24 2.18 C 9.209 2.073 9.111 2 9 2 C 8.889 2 8.791 2.073 8.76 2.18 L 6.41 10.54 C 6.168 11.401 5.384 11.997 4.49 12 L 2 12"",
IconName.TrendingUp => ""M 22 7 L 13.5 15.5 L 8.5 10.5 L 2 17 M 16 7 L 22 7 L 22 13"",
- IconName.ChartColumn => ""M 3 3 L 3 19 A 2 2 0 0 0 5 21 L 21 21 M 18 17 L 18 9 M 13 17 L 13 5 M 8 17 L 8 14"",
- IconName.Zap => ""M 4 14 A 1 1 0 0 1 3.22 12.37 L 13.12 2.17 A 0.5 0.5 0 0 1 13.98 2.63 L 12.06 8.65 A 1 1 0 0 0 13 10 L 20 10 A 1 1 0 0 1 20.78 11.63 L 10.88 21.83 A 0.5 0.5 0 0 1 10.02 21.37 L 11.94 15.35 A 1 1 0 0 0 11 14 Z"",
- IconName.Sparkles => ""M 9.937 15.5 A 2 2 0 0 0 8.5 14.063 L 2.365 12.481 A 0.5 0.5 0 0 1 2.365 11.519 L 8.5 9.936 A 2 2 0 0 0 9.937 8.5 L 11.519 2.365 A 0.5 0.5 0 0 1 12.482 2.365 L 14.063 8.5 A 2 2 0 0 0 15.5 9.937 L 21.635 11.518 A 0.5 0.5 0 0 1 21.635 12.482 L 15.5 14.063 A 2 2 0 0 0 14.063 15.5 L 12.481 21.635 A 0.5 0.5 0 0 1 11.518 21.635 Z M 20 3 L 20 7 M 22 5 L 18 5 M 4 17 L 4 19 M 5 18 L 3 18"",
- IconName.Command => ""M 15 6 L 15 18 A 3 3 0 1 0 18 15 L 6 15 A 3 3 0 1 0 9 18 L 9 6 A 3 3 0 1 0 6 9 L 18 9 A 3 3 0 1 0 15 6"",
+ IconName.ChartColumn => ""M 3 3 L 3 19 C 3 20.105 3.895 21 5 21 L 21 21 M 18 17 L 18 9 M 13 17 L 13 5 M 8 17 L 8 14"",
+ IconName.Zap => ""M 4 14 C 3.614 14.001 3.261 13.78 3.095 13.432 C 2.928 13.083 2.977 12.67 3.22 12.37 L 13.12 2.17 C 13.273 1.993 13.528 1.946 13.734 2.057 C 13.94 2.167 14.042 2.405 13.98 2.63 L 12.06 8.65 C 11.945 8.958 11.988 9.302 12.176 9.571 C 12.364 9.841 12.672 10.001 13 10 L 20 10 C 20.386 9.999 20.739 10.22 20.905 10.568 C 21.072 10.917 21.023 11.33 20.78 11.63 L 10.88 21.83 C 10.727 22.007 10.472 22.054 10.266 21.943 C 10.06 21.833 9.958 21.595 10.02 21.37 L 11.94 15.35 C 12.055 15.042 12.012 14.698 11.824 14.429 C 11.636 14.159 11.328 13.999 11 14 Z"",
+ IconName.Sparkles => ""M 9.937 15.5 C 9.755 14.795 9.205 14.245 8.5 14.063 L 2.365 12.481 C 2.15 12.42 2.002 12.224 2.002 12 C 2.002 11.776 2.15 11.58 2.365 11.519 L 8.5 9.936 C 9.205 9.754 9.755 9.204 9.937 8.5 L 11.519 2.365 C 11.579 2.149 11.776 2 12 2 C 12.225 2 12.422 2.149 12.482 2.365 L 14.063 8.5 C 14.245 9.205 14.795 9.755 15.5 9.937 L 21.635 11.518 C 21.852 11.578 22.002 11.775 22.002 12 C 22.002 12.225 21.852 12.422 21.635 12.482 L 15.5 14.063 C 14.795 14.245 14.245 14.795 14.063 15.5 L 12.481 21.635 C 12.421 21.851 12.224 22 12 22 C 11.775 22 11.578 21.851 11.518 21.635 Z M 20 3 L 20 7 M 22 5 L 18 5 M 4 17 L 4 19 M 5 18 L 3 18"",
+ IconName.Command => ""M 15 6 L 15 18 C 15 19.657 16.343 21 18 21 C 19.657 21 21 19.657 21 18 C 21 16.343 19.657 15 18 15 L 6 15 C 4.343 15 3 16.343 3 18 C 3 19.657 4.343 21 6 21 C 7.657 21 9 19.657 9 18 L 9 6 C 9 4.343 7.657 3 6 3 C 4.343 3 3 4.343 3 6 C 3 7.657 4.343 9 6 9 L 18 9 C 19.657 9 21 7.657 21 6 C 21 4.343 19.657 3 18 3 C 16.343 3 15 4.343 15 6"",
IconName.Terminal => ""M 4 17 L 10 11 L 4 5 M 12 19 L 20 19"",
IconName.Code => ""M 16 18 L 22 12 L 16 6 M 8 6 L 2 12 L 8 18"",
- IconName.Moon => ""M 12 3 A 6 6 0 0 0 21 12 A 9 9 0 1 1 12 3 Z"",
- IconName.Sun => ""M 16 12 A 4 4 0 1 1 8 12 A 4 4 0 1 1 16 12 Z M 12 2 L 12 4 M 12 20 L 12 22 M 4.93 4.93 L 6.34 6.34 M 17.66 17.66 L 19.07 19.07 M 2 12 L 4 12 M 20 12 L 22 12 M 6.34 17.66 L 4.93 19.07 M 19.07 4.93 L 17.66 6.34"",
- IconName.Monitor => ""M 4 3 L 20 3 A 2 2 0 0 1 22 5 L 22 15 A 2 2 0 0 1 20 17 L 4 17 A 2 2 0 0 1 2 15 L 2 5 A 2 2 0 0 1 4 3 Z M 8 21 L 16 21 M 12 17 L 12 21"",
- IconName.Smartphone => ""M 7 2 L 17 2 A 2 2 0 0 1 19 4 L 19 20 A 2 2 0 0 1 17 22 L 7 22 A 2 2 0 0 1 5 20 L 5 4 A 2 2 0 0 1 7 2 Z M 12 18 L 12.01 18"",
- IconName.Laptop => ""M 20 16 L 20 7 A 2 2 0 0 0 18 5 L 6 5 A 2 2 0 0 0 4 7 L 4 16 M 20 16 L 4 16 M 20 16 L 21.28 18.55 A 1 1 0 0 1 20.38 20 L 3.62 20 A 1 1 0 0 1 2.72 18.55 L 4 16"",
- IconName.Wifi => ""M 12 20 L 12.01 20 M 2 8.82 A 15 15 0 0 1 22 8.82 M 5 12.859 A 10 10 0 0 1 19 12.859 M 8.5 16.429 A 5 5 0 0 1 15.5 16.429"",
- IconName.Camera => ""M 14.5 4 L 9.5 4 L 7 7 L 4 7 A 2 2 0 0 0 2 9 L 2 18 A 2 2 0 0 0 4 20 L 20 20 A 2 2 0 0 0 22 18 L 22 9 A 2 2 0 0 0 20 7 L 17 7 L 14.5 4 Z M 15 13 A 3 3 0 1 1 9 13 A 3 3 0 1 1 15 13 Z"",
- IconName.Mic => ""M 12 2 A 3 3 0 0 0 9 5 L 9 12 A 3 3 0 0 0 15 12 L 15 5 A 3 3 0 0 0 12 2 Z M 19 10 L 19 12 A 7 7 0 0 1 5 12 L 5 10 M 12 19 L 12 22"",
+ IconName.Moon => ""M 12 3 C 9.515 5.485 9.515 9.515 12 12 C 14.485 14.485 18.515 14.485 21 12 C 21 16.971 16.971 21 12 21 C 7.029 21 3 16.971 3 12 C 3 7.029 7.029 3 12 3 Z"",
+ IconName.Sun => ""M 16 12 C 16 14.209 14.209 16 12 16 C 9.791 16 8 14.209 8 12 C 8 9.791 9.791 8 12 8 C 14.209 8 16 9.791 16 12 Z M 12 2 L 12 4 M 12 20 L 12 22 M 4.93 4.93 L 6.34 6.34 M 17.66 17.66 L 19.07 19.07 M 2 12 L 4 12 M 20 12 L 22 12 M 6.34 17.66 L 4.93 19.07 M 19.07 4.93 L 17.66 6.34"",
+ IconName.Monitor => ""M 4 3 L 20 3 C 21.105 3 22 3.895 22 5 L 22 15 C 22 16.105 21.105 17 20 17 L 4 17 C 2.895 17 2 16.105 2 15 L 2 5 C 2 3.895 2.895 3 4 3 Z M 8 21 L 16 21 M 12 17 L 12 21"",
+ IconName.Smartphone => ""M 7 2 L 17 2 C 18.105 2 19 2.895 19 4 L 19 20 C 19 21.105 18.105 22 17 22 L 7 22 C 5.895 22 5 21.105 5 20 L 5 4 C 5 2.895 5.895 2 7 2 Z M 12 18 L 12.01 18"",
+ IconName.Laptop => ""M 20 16 L 20 7 C 20 5.895 19.105 5 18 5 L 6 5 C 4.895 5 4 5.895 4 7 L 4 16 M 20 16 L 4 16 M 20 16 L 21.28 18.55 C 21.437 18.861 21.42 19.231 21.237 19.527 C 21.053 19.823 20.728 20.002 20.38 20 L 3.62 20 C 3.272 20.002 2.947 19.823 2.763 19.527 C 2.58 19.231 2.563 18.861 2.72 18.55 L 4 16"",
+ IconName.Wifi => ""M 12 20 L 12.01 20 M 2 8.82 C 7.694 3.727 16.306 3.727 22 8.82 M 5 12.859 C 8.888 9.048 15.112 9.048 19 12.859 M 8.5 16.429 C 10.444 14.523 13.556 14.523 15.5 16.429"",
+ IconName.Camera => ""M 14.5 4 L 9.5 4 L 7 7 L 4 7 C 2.895 7 2 7.895 2 9 L 2 18 C 2 19.105 2.895 20 4 20 L 20 20 C 21.105 20 22 19.105 22 18 L 22 9 C 22 7.895 21.105 7 20 7 L 17 7 L 14.5 4 Z M 15 13 C 15 14.657 13.657 16 12 16 C 10.343 16 9 14.657 9 13 C 9 11.343 10.343 10 12 10 C 13.657 10 15 11.343 15 13 Z"",
+ IconName.Mic => ""M 12 2 C 10.343 2 9 3.343 9 5 L 9 12 C 9 13.657 10.343 15 12 15 C 13.657 15 15 13.657 15 12 L 15 5 C 15 3.343 13.657 2 12 2 Z M 19 10 L 19 12 C 19 15.866 15.866 19 12 19 C 8.134 19 5 15.866 5 12 L 5 10 M 12 19 L 12 22"",
IconName.Play => ""M 6 3 L 20 12 L 6 21 L 6 3 Z"",
- IconName.Pause => ""M 15 4 L 17 4 A 1 1 0 0 1 18 5 L 18 19 A 1 1 0 0 1 17 20 L 15 20 A 1 1 0 0 1 14 19 L 14 5 A 1 1 0 0 1 15 4 Z M 7 4 L 9 4 A 1 1 0 0 1 10 5 L 10 19 A 1 1 0 0 1 9 20 L 7 20 A 1 1 0 0 1 6 19 L 6 5 A 1 1 0 0 1 7 4 Z"",
- IconName.Volume2 => ""M 11 4.702 A 0.705 0.705 0 0 0 9.797 4.204 L 6.413 7.587 A 1.4 1.4 0 0 1 5.416 8 L 3 8 A 1 1 0 0 0 2 9 L 2 15 A 1 1 0 0 0 3 16 L 5.416 16 A 1.4 1.4 0 0 1 6.413 16.413 L 9.796 19.797 A 0.705 0.705 0 0 0 11 19.298 Z M 16 9 A 5 5 0 0 1 16 15 M 19.364 18.364 A 9 9 0 0 0 19.364 5.636"",
- IconName.Lightbulb => ""M 15 14 C 15.2 13 15.7 12.3 16.5 11.5 C 17.5 10.6 18 9.3 18 8 A 6 6 0 0 0 6 8 C 6 9 6.2 10.2 7.5 11.5 C 8.2 12.2 8.8 13 9 14 M 9 18 L 15 18 M 10 22 L 14 22"",
+ IconName.Pause => ""M 15 4 L 17 4 C 17.552 4 18 4.448 18 5 L 18 19 C 18 19.552 17.552 20 17 20 L 15 20 C 14.448 20 14 19.552 14 19 L 14 5 C 14 4.448 14.448 4 15 4 Z M 7 4 L 9 4 C 9.552 4 10 4.448 10 5 L 10 19 C 10 19.552 9.552 20 9 20 L 7 20 C 6.448 20 6 19.552 6 19 L 6 5 C 6 4.448 6.448 4 7 4 Z"",
+ IconName.Volume2 => ""M 11 4.702 C 11 4.417 10.828 4.161 10.565 4.052 C 10.301 3.943 9.999 4.003 9.797 4.204 L 6.413 7.587 C 6.149 7.852 5.79 8.001 5.416 8 L 3 8 C 2.448 8 2 8.448 2 9 L 2 15 C 2 15.552 2.448 16 3 16 L 5.416 16 C 5.79 15.999 6.149 16.148 6.413 16.413 L 9.796 19.797 C 9.998 19.999 10.301 20.06 10.565 19.95 C 10.829 19.841 11 19.583 11 19.298 Z M 16 9 C 17.333 10.778 17.333 13.222 16 15 M 19.364 18.364 C 21.052 16.676 22 14.387 22 12 C 22 9.613 21.052 7.324 19.364 5.636"",
+ IconName.Lightbulb => ""M 15 14 C 15.2 13 15.7 12.3 16.5 11.5 C 17.5 10.6 18 9.3 18 8 C 18 4.686 15.314 2 12 2 C 8.686 2 6 4.686 6 8 C 6 9 6.2 10.2 7.5 11.5 C 8.2 12.2 8.8 13 9 14 M 9 18 L 15 18 M 10 22 L 14 22"",
_ => string.Empty
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/KbdTemplate.cs b/src/ShellUI.Native.Templates/Templates/KbdTemplate.cs
new file mode 100644
index 0000000..1bdbda8
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/KbdTemplate.cs
@@ -0,0 +1,71 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class KbdTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "kbd",
+ DisplayName = "Kbd",
+ Description = "Keyboard key hint",
+ Category = ComponentCategory.DataDisplay,
+ FilePath = "Kbd.cs",
+ Dependencies = new List { "shell" },
+ Tags = new List { "keyboard", "shortcut", "key" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"using Microsoft.Maui.Controls.Shapes;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Keyboard key — h-5 rounded border bg-muted px-1.5 text-[11px] font-medium text-muted-foreground.
+// Usage:
+public partial class Kbd : ContentView
+{
+ public static readonly BindableProperty TextProperty =
+ BindableProperty.Create(nameof(Text), typeof(string), typeof(Kbd), string.Empty,
+ propertyChanged: (b, o, n) => ((Kbd)b)._label.Text = n as string ?? string.Empty);
+
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ private readonly Label _label;
+
+ public Kbd()
+ {
+ _label = new Label
+ {
+ FontSize = 11,
+ FontAttributes = FontAttributes.Bold,
+ HorizontalTextAlignment = TextAlignment.Center,
+ VerticalTextAlignment = TextAlignment.Center,
+ LineBreakMode = LineBreakMode.NoWrap
+ };
+ _label.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+
+ var key = new Border
+ {
+ Content = _label,
+ HeightRequest = 22,
+ MinimumWidthRequest = 22,
+ Padding = new Thickness(6, 0),
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusSm }
+ };
+ key.Token(VisualElement.BackgroundColorProperty, ShellToken.Muted);
+ key.Token(Border.StrokeProperty, ShellToken.Border);
+
+ Content = key;
+ HorizontalOptions = LayoutOptions.Start;
+ VerticalOptions = LayoutOptions.Center;
+ }
+}
+"
+ };
+}
diff --git a/src/ShellUI.Native.Templates/Templates/LinkCardTemplate.cs b/src/ShellUI.Native.Templates/Templates/LinkCardTemplate.cs
new file mode 100644
index 0000000..fc37412
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/LinkCardTemplate.cs
@@ -0,0 +1,148 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class LinkCardTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "link-card",
+ DisplayName = "Link Card",
+ Description = "Tappable card with icon, title and description",
+ Category = ComponentCategory.Navigation,
+ FilePath = "LinkCard.cs",
+ Dependencies = new List { "shell", "icon" },
+ Tags = new List { "link", "card", "navigation" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"using Microsoft.Maui.Controls.Shapes;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Tappable card — rounded-lg border bg-card p-4, hover:bg-accent: an optional icon, a title, a
+// muted description and an arrow. Tapping raises Clicked and opens Url when one is set.
+// Usage:
+public partial class LinkCard : ContentView
+{
+ public static readonly BindableProperty TitleProperty =
+ BindableProperty.Create(nameof(Title), typeof(string), typeof(LinkCard), string.Empty,
+ propertyChanged: (b, o, n) => ((LinkCard)b).Update());
+
+ public static readonly BindableProperty DescriptionProperty =
+ BindableProperty.Create(nameof(Description), typeof(string), typeof(LinkCard), string.Empty,
+ propertyChanged: (b, o, n) => ((LinkCard)b).Update());
+
+ public static readonly BindableProperty IconProperty =
+ BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(LinkCard), IconName.None,
+ propertyChanged: (b, o, n) => ((LinkCard)b).Update());
+
+ // Opened in the system browser (or the app registered for the link) when the card is tapped.
+ public static readonly BindableProperty UrlProperty =
+ BindableProperty.Create(nameof(Url), typeof(string), typeof(LinkCard), string.Empty);
+
+ public string Title
+ {
+ get => (string)GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public string Description
+ {
+ get => (string)GetValue(DescriptionProperty);
+ set => SetValue(DescriptionProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => (IconName)GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public string Url
+ {
+ get => (string)GetValue(UrlProperty);
+ set => SetValue(UrlProperty, value);
+ }
+
+ public event EventHandler? Clicked;
+
+ private readonly Border _card;
+ private readonly Icon _icon;
+ private readonly Label _title;
+ private readonly Label _description;
+
+ public LinkCard()
+ {
+ _icon = new Icon { Size = 20, Token = ShellToken.MutedForeground, VerticalOptions = LayoutOptions.Start, Margin = new Thickness(0, 1, 0, 0) };
+ _title = new Label { FontSize = 15, FontAttributes = FontAttributes.Bold, LineBreakMode = LineBreakMode.TailTruncation };
+ _title.Token(Label.TextColorProperty, ShellToken.CardForeground);
+ _description = new Label { FontSize = 14 };
+ _description.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+ var arrow = new Icon { Name = IconName.ArrowUpRight, Size = 16, Token = ShellToken.MutedForeground, VerticalOptions = LayoutOptions.Start, Margin = new Thickness(0, 2, 0, 0) };
+
+ var row = new Grid
+ {
+ ColumnSpacing = 12,
+ ColumnDefinitions =
+ {
+ new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto)
+ }
+ };
+ row.Add(_icon, 0, 0);
+ row.Add(new VerticalStackLayout { Spacing = 4, Children = { _title, _description } }, 1, 0);
+ row.Add(arrow, 2, 0);
+
+ _card = new Border
+ {
+ Content = row,
+ Padding = new Thickness(16),
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusLg }
+ };
+ _card.Token(VisualElement.BackgroundColorProperty, ShellToken.Card);
+ _card.Token(Border.StrokeProperty, ShellToken.Border);
+
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => _card.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent);
+ pointer.PointerExited += (_, _) => _card.Token(VisualElement.BackgroundColorProperty, ShellToken.Card);
+ _card.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { ShellFocus.FocusPressed(this); Press(); };
+ _card.GestureRecognizers.Add(tap);
+ ShellFocus.MakeFocusable(this, Press);
+
+ Content = _card;
+ Update();
+ }
+
+ private async void Press()
+ {
+ if (!IsEnabled) return;
+ Clicked?.Invoke(this, EventArgs.Empty);
+ if (string.IsNullOrWhiteSpace(Url) || !Uri.TryCreate(Url, UriKind.Absolute, out var uri)) return;
+ try
+ {
+ await Launcher.Default.OpenAsync(uri);
+ }
+ catch (Exception)
+ {
+ // Nothing on the device can open this link.
+ }
+ }
+
+ private void Update()
+ {
+ _icon.Name = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ _title.Text = Title ?? string.Empty;
+ _title.IsVisible = !string.IsNullOrEmpty(Title);
+ _description.Text = Description ?? string.Empty;
+ _description.IsVisible = !string.IsNullOrEmpty(Description);
+ }
+}
+"
+ };
+}
diff --git a/src/ShellUI.Native.Templates/Templates/MultiSelectTemplate.cs b/src/ShellUI.Native.Templates/Templates/MultiSelectTemplate.cs
new file mode 100644
index 0000000..01d6f8e
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/MultiSelectTemplate.cs
@@ -0,0 +1,374 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class MultiSelectTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "multi-select",
+ DisplayName = "Multi Select",
+ Description = "Searchable select for several options, shown as chips",
+ Category = ComponentCategory.Form,
+ FilePath = "MultiSelect.cs",
+ Dependencies = new List { "shell", "icon", "wrap-layout" },
+ Tags = new List { "select", "multiple", "chips", "search" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"using System.Collections.Specialized;
+using Microsoft.Maui.Controls.Shapes;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Multi-select — a min-h-10 trigger that shows the picked options as removable chips, and a
+// floating panel with a search field and a checked list. Picking toggles an option and leaves
+// the panel open; click outside or press Escape to close it.
+// Usage: then
+// set ItemsSource in code or by binding.
+public partial class MultiSelect : ContentView, IShellPopup
+{
+ public static readonly BindableProperty ItemsSourceProperty =
+ BindableProperty.Create(nameof(ItemsSource), typeof(IList), typeof(MultiSelect), null,
+ propertyChanged: (b, o, n) => ((MultiSelect)b).RebuildItems());
+
+ public static readonly BindableProperty ValuesProperty =
+ BindableProperty.Create(nameof(Values), typeof(IList), typeof(MultiSelect), null, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((MultiSelect)b).OnValuesReplaced(o, n));
+
+ public static readonly BindableProperty PlaceholderProperty =
+ BindableProperty.Create(nameof(Placeholder), typeof(string), typeof(MultiSelect), ""Select..."",
+ propertyChanged: (b, o, n) => ((MultiSelect)b)._placeholder.Text = (string)n);
+
+ public static readonly BindableProperty SearchPlaceholderProperty =
+ BindableProperty.Create(nameof(SearchPlaceholder), typeof(string), typeof(MultiSelect), ""Search..."",
+ propertyChanged: (b, o, n) => ((MultiSelect)b)._search.Placeholder = (string)n);
+
+ public static readonly BindableProperty EmptyTextProperty =
+ BindableProperty.Create(nameof(EmptyText), typeof(string), typeof(MultiSelect), ""No results found."",
+ propertyChanged: (b, o, n) => ((MultiSelect)b)._empty.Text = (string)n);
+
+ public IList? ItemsSource
+ {
+ get => (IList?)GetValue(ItemsSourceProperty);
+ set => SetValue(ItemsSourceProperty, value);
+ }
+
+ public IList? Values
+ {
+ get => (IList?)GetValue(ValuesProperty);
+ set => SetValue(ValuesProperty, value);
+ }
+
+ public string Placeholder
+ {
+ get => (string)GetValue(PlaceholderProperty);
+ set => SetValue(PlaceholderProperty, value);
+ }
+
+ public string SearchPlaceholder
+ {
+ get => (string)GetValue(SearchPlaceholderProperty);
+ set => SetValue(SearchPlaceholderProperty, value);
+ }
+
+ public string EmptyText
+ {
+ get => (string)GetValue(EmptyTextProperty);
+ set => SetValue(EmptyTextProperty, value);
+ }
+
+ public bool IsOpen { get; private set; }
+
+ public event EventHandler>? ValuesChanged;
+
+ private const double ItemHeight = 32;
+ private const double MaxListHeight = 240;
+
+ private readonly Border _trigger;
+ private readonly WrapLayout _chips;
+ private readonly Label _placeholder;
+ private readonly Border _panel;
+ private readonly Entry _search;
+ private readonly VerticalStackLayout _list;
+ private readonly Grid _listArea;
+ private readonly Label _empty;
+ private readonly List<(string Text, Border Row, Icon Check)> _rows = new();
+ private ShellPopupHandle? _handle;
+ private bool _chipTapped;
+
+ public MultiSelect()
+ {
+ _placeholder = new Label { Text = Placeholder, FontSize = 14, VerticalTextAlignment = TextAlignment.Center, VerticalOptions = LayoutOptions.Center, Margin = new Thickness(4, 0, 0, 0) };
+ _placeholder.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+ _chips = new WrapLayout { Spacing = 4, LineSpacing = 4, VerticalOptions = LayoutOptions.Center };
+
+ var triggerRow = new Grid
+ {
+ ColumnSpacing = 8,
+ ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto) }
+ };
+ triggerRow.Add(new Grid { Children = { _placeholder, _chips } }, 0, 0);
+ triggerRow.Add(new Icon { Name = IconName.ChevronsUpDown, Size = 16, Token = ShellToken.MutedForeground, VerticalOptions = LayoutOptions.Center }, 1, 0);
+
+ _trigger = new Border
+ {
+ Content = triggerRow,
+ MinimumHeightRequest = 40,
+ Padding = new Thickness(8, 6, 12, 6),
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ BackgroundColor = Colors.Transparent
+ };
+ _trigger.Token(Border.StrokeProperty, ShellToken.Input);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) =>
+ {
+ // A tap on a chip's × can reach the trigger too; it must not toggle the panel.
+ if (_chipTapped) { _chipTapped = false; return; }
+ if (!IsEnabled) return;
+ ShellFocus.FocusPressed(this);
+ SetOpen(!IsOpen);
+ };
+ _trigger.GestureRecognizers.Add(tap);
+ ShellFocus.MakeFocusable(this, () => { if (IsEnabled) SetOpen(!IsOpen); });
+
+ _search = new Entry
+ {
+ Placeholder = SearchPlaceholder,
+ FontSize = 14,
+ BackgroundColor = Colors.Transparent,
+ VerticalOptions = LayoutOptions.Center,
+ ClearButtonVisibility = ClearButtonVisibility.Never,
+ IsTextPredictionEnabled = false,
+ IsSpellCheckEnabled = false
+ };
+ _search.Token(Entry.TextColorProperty, ShellToken.PopoverForeground);
+ _search.Token(Entry.PlaceholderColorProperty, ShellToken.MutedForeground);
+ ShellPlatform.StripNativeChrome(_search);
+ ShellFocus.Track(_search);
+ _search.TextChanged += (_, _) => Filter();
+ // Enter toggles the first match.
+ _search.Completed += (_, _) =>
+ {
+ var first = _rows.FirstOrDefault(r => r.Row.IsVisible);
+ if (first.Row != null) Toggle(first.Text);
+ };
+
+ var searchRow = new Grid
+ {
+ HeightRequest = 40,
+ Padding = new Thickness(12, 0),
+ ColumnSpacing = 8,
+ ColumnDefinitions = { new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star) }
+ };
+ searchRow.Add(new Icon { Name = IconName.Search, Size = 16, Token = ShellToken.MutedForeground }, 0, 0);
+ searchRow.Add(_search, 1, 0);
+
+ var divider = new BoxView { HeightRequest = 1, BackgroundColor = Colors.Transparent };
+ divider.Token(BoxView.ColorProperty, ShellToken.Border);
+
+ _list = new VerticalStackLayout { Spacing = 0 };
+ _empty = new Label
+ {
+ Text = EmptyText,
+ FontSize = 14,
+ HorizontalOptions = LayoutOptions.Center,
+ VerticalOptions = LayoutOptions.Center,
+ IsVisible = false
+ };
+ _empty.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+ // Fixed height (set in RebuildItems): the panel keeps its size and position while filtering.
+ _listArea = new Grid { Margin = new Thickness(4), Children = { new ScrollView { Content = _list }, _empty } };
+
+ _panel = new Border
+ {
+ Content = new VerticalStackLayout { Spacing = 0, Children = { searchRow, divider, _listArea } },
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ Shadow = ShellPopups.PanelShadow()
+ };
+ _panel.Token(VisualElement.BackgroundColorProperty, ShellToken.Popover);
+ _panel.Token(Border.StrokeProperty, ShellToken.Border);
+ // Taps on the panel's own chrome must not fall through to the click-outside catcher.
+ _panel.GestureRecognizers.Add(new TapGestureRecognizer());
+
+ Content = _trigger;
+ RebuildItems();
+ RebuildChips();
+ }
+
+ private IReadOnlyList Current => (IReadOnlyList?)Values?.ToList() ?? Array.Empty();
+
+ public void SetOpen(bool open)
+ {
+ if (IsOpen == open) return;
+ IsOpen = open;
+ _trigger.Token(Border.StrokeProperty, open ? ShellToken.Ring : ShellToken.Input);
+ if (open)
+ {
+ _search.Text = string.Empty;
+ Filter();
+ ShellPopups.Opened(this);
+ _handle = ShellPortal.ShowPopup(_trigger, _panel, new ShellPopupOptions
+ {
+ MatchAnchorWidth = true,
+ Owner = this,
+ OnDismiss = Close
+ });
+ // Desktop only: on touch the soft keyboard would cover the list.
+ if (DeviceInfo.Idiom == DeviceIdiom.Desktop)
+ Dispatcher.DispatchDelayed(TimeSpan.FromMilliseconds(150), () => { if (IsOpen) _search.Focus(); });
+ }
+ else
+ {
+ ShellPopups.Closed(this);
+ if (_search.IsFocused) _search.Unfocus();
+ var handle = _handle;
+ _handle = null;
+ if (handle != null) _ = handle.CloseAsync();
+ }
+ }
+
+ public void Close() => SetOpen(false);
+
+ // Adds the option when it isn't picked, removes it when it is.
+ public void Toggle(string text)
+ {
+ // A new list each time, so a two-way binding sees the change.
+ var next = new List(Current);
+ if (!next.Remove(text)) next.Add(text);
+ Values = next;
+ }
+
+ private void OnValuesReplaced(object? oldValue, object? newValue)
+ {
+ if (oldValue is INotifyCollectionChanged before) before.CollectionChanged -= OnValuesMutated;
+ if (newValue is INotifyCollectionChanged after) after.CollectionChanged += OnValuesMutated;
+ OnValuesMutated(this, null);
+ }
+
+ private void OnValuesMutated(object? sender, NotifyCollectionChangedEventArgs? e)
+ {
+ RebuildChips();
+ UpdateChecks();
+ ValuesChanged?.Invoke(this, Current);
+ }
+
+ private void RebuildChips()
+ {
+ _chips.Children.Clear();
+ var current = Current;
+ foreach (var value in current)
+ _chips.Children.Add(CreateChip(value));
+ _placeholder.IsVisible = current.Count == 0;
+ }
+
+ private View CreateChip(string value)
+ {
+ var label = new Label { Text = value, FontSize = 12, FontAttributes = FontAttributes.Bold, VerticalTextAlignment = TextAlignment.Center, LineBreakMode = LineBreakMode.TailTruncation };
+ label.Token(Label.TextColorProperty, ShellToken.SecondaryForeground);
+ var close = new Icon { Name = IconName.X, Size = 12, Token = ShellToken.MutedForeground, HorizontalOptions = LayoutOptions.Center, VerticalOptions = LayoutOptions.Center };
+ // 12px icon in a 20px tap target.
+ var remove = new Grid { WidthRequest = 20, HeightRequest = 20, BackgroundColor = Colors.Transparent, Children = { close } };
+ SemanticProperties.SetDescription(remove, $""Remove {value}"");
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => close.Token = ShellToken.Destructive;
+ pointer.PointerExited += (_, _) => close.Token = ShellToken.MutedForeground;
+ remove.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) =>
+ {
+ if (!IsEnabled) return;
+ _chipTapped = true;
+ // Cleared on the next turn in case the tap never reaches the trigger.
+ Dispatcher.Dispatch(() => _chipTapped = false);
+ Toggle(value);
+ };
+ remove.GestureRecognizers.Add(tap);
+
+ var chip = new Border
+ {
+ Content = new HorizontalStackLayout { Spacing = 2, Children = { label, remove } },
+ Padding = new Thickness(8, 0, 2, 0),
+ HeightRequest = 24,
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }
+ };
+ chip.Token(VisualElement.BackgroundColorProperty, ShellToken.Secondary);
+ return chip;
+ }
+
+ private void RebuildItems()
+ {
+ _list.Children.Clear();
+ _rows.Clear();
+ var items = ItemsSource;
+ _listArea.HeightRequest = Math.Min(Math.Max(items?.Count ?? 0, 2) * ItemHeight, MaxListHeight);
+ if (items != null)
+ {
+ foreach (var text in items)
+ _list.Children.Add(CreateItem(text));
+ }
+ UpdateChecks();
+ Filter();
+ }
+
+ private View CreateItem(string text)
+ {
+ var check = new Icon { Name = IconName.Check, Size = 16, Opacity = 0 };
+ var label = new Label { Text = text, FontSize = 14, VerticalOptions = LayoutOptions.Center, VerticalTextAlignment = TextAlignment.Center, LineBreakMode = LineBreakMode.TailTruncation };
+ label.Token(Label.TextColorProperty, ShellToken.PopoverForeground);
+ var row = new Grid
+ {
+ ColumnSpacing = 8,
+ ColumnDefinitions = { new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star) }
+ };
+ row.Add(check, 0, 0);
+ row.Add(label, 1, 0);
+
+ var item = new Border
+ {
+ Content = row,
+ HeightRequest = ItemHeight,
+ Padding = new Thickness(8, 0),
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusSm },
+ BackgroundColor = Colors.Transparent
+ };
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => item.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent);
+ pointer.PointerExited += (_, _) => { item.ClearValue(VisualElement.BackgroundColorProperty); item.BackgroundColor = Colors.Transparent; };
+ item.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => Toggle(text);
+ item.GestureRecognizers.Add(tap);
+
+ _rows.Add((text, item, check));
+ return item;
+ }
+
+ private void UpdateChecks()
+ {
+ var current = Current;
+ // The check keeps its column when hidden, so option labels stay aligned.
+ foreach (var (text, _, check) in _rows)
+ check.Opacity = current.Contains(text) ? 1 : 0;
+ }
+
+ private void Filter()
+ {
+ var query = _search.Text?.Trim() ?? string.Empty;
+ var any = false;
+ foreach (var (text, row, _) in _rows)
+ {
+ row.IsVisible = query.Length == 0 || text.Contains(query, StringComparison.OrdinalIgnoreCase);
+ any |= row.IsVisible;
+ }
+ _empty.IsVisible = !any;
+ }
+}
+"
+ };
+}
diff --git a/src/ShellUI.Native.Templates/Templates/NumberInputTemplate.cs b/src/ShellUI.Native.Templates/Templates/NumberInputTemplate.cs
new file mode 100644
index 0000000..7e1b389
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/NumberInputTemplate.cs
@@ -0,0 +1,224 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class NumberInputTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "number-input",
+ DisplayName = "Number Input",
+ Description = "Number field with decrement and increment buttons",
+ Category = ComponentCategory.Form,
+ FilePath = "NumberInput.cs",
+ Dependencies = new List { "shell", "icon" },
+ Tags = new List { "number", "input", "stepper", "quantity" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"using System.Globalization;
+using Microsoft.Maui.Controls.Shapes;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Number field with − and + buttons — h-10 rounded-md border, the value centered between them.
+// The value is clamped to Minimum / Maximum; the buttons dim at the limits.
+// Usage:
+public partial class NumberInput : ContentView
+{
+ public static readonly BindableProperty ValueProperty =
+ BindableProperty.Create(nameof(Value), typeof(decimal?), typeof(NumberInput), null, BindingMode.TwoWay,
+ coerceValue: (b, v) => ((NumberInput)b).Clamp((decimal?)v),
+ propertyChanged: (b, o, n) => ((NumberInput)b).OnValueChanged());
+
+ public static readonly BindableProperty MinimumProperty =
+ BindableProperty.Create(nameof(Minimum), typeof(decimal?), typeof(NumberInput), null,
+ propertyChanged: (b, o, n) => ((NumberInput)b).OnLimitsChanged());
+
+ public static readonly BindableProperty MaximumProperty =
+ BindableProperty.Create(nameof(Maximum), typeof(decimal?), typeof(NumberInput), null,
+ propertyChanged: (b, o, n) => ((NumberInput)b).OnLimitsChanged());
+
+ public static readonly BindableProperty StepProperty =
+ BindableProperty.Create(nameof(Step), typeof(decimal), typeof(NumberInput), 1m);
+
+ public static readonly BindableProperty PlaceholderProperty =
+ BindableProperty.Create(nameof(Placeholder), typeof(string), typeof(NumberInput), string.Empty,
+ propertyChanged: (b, o, n) => ((NumberInput)b)._entry.Placeholder = (string)n);
+
+ public decimal? Value
+ {
+ get => (decimal?)GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public decimal? Minimum
+ {
+ get => (decimal?)GetValue(MinimumProperty);
+ set => SetValue(MinimumProperty, value);
+ }
+
+ public decimal? Maximum
+ {
+ get => (decimal?)GetValue(MaximumProperty);
+ set => SetValue(MaximumProperty, value);
+ }
+
+ public decimal Step
+ {
+ get => (decimal)GetValue(StepProperty);
+ set => SetValue(StepProperty, value);
+ }
+
+ public string Placeholder
+ {
+ get => (string)GetValue(PlaceholderProperty);
+ set => SetValue(PlaceholderProperty, value);
+ }
+
+ public event EventHandler? ValueChanged;
+
+ private readonly Border _frame;
+ private readonly Entry _entry;
+ private readonly Grid _decrease;
+ private readonly Grid _increase;
+
+ public NumberInput()
+ {
+ _entry = new Entry
+ {
+ Keyboard = Keyboard.Numeric,
+ FontSize = 14,
+ HorizontalTextAlignment = TextAlignment.Center,
+ VerticalOptions = LayoutOptions.Center,
+ BackgroundColor = Colors.Transparent
+ };
+ _entry.Token(Entry.TextColorProperty, ShellToken.Foreground);
+ _entry.Token(Entry.PlaceholderColorProperty, ShellToken.MutedForeground);
+ ShellPlatform.StripNativeChrome(_entry);
+ ShellFocus.Track(_entry);
+ _entry.Focused += (_, _) => _frame!.Token(Border.StrokeProperty, ShellToken.Ring);
+ _entry.Unfocused += (_, _) => { _frame!.Token(Border.StrokeProperty, ShellToken.Input); Commit(); };
+ _entry.Completed += (_, _) => Commit();
+
+ _decrease = StepButton(IconName.Minus, ""Decrease"", -1);
+ _increase = StepButton(IconName.Plus, ""Increase"", 1);
+
+ var row = new Grid
+ {
+ ColumnDefinitions =
+ {
+ new ColumnDefinition(40), new ColumnDefinition(1), new ColumnDefinition(GridLength.Star),
+ new ColumnDefinition(1), new ColumnDefinition(40)
+ }
+ };
+ row.Add(_decrease, 0, 0);
+ row.Add(Divider(), 1, 0);
+ row.Add(_entry, 2, 0);
+ row.Add(Divider(), 3, 0);
+ row.Add(_increase, 4, 0);
+
+ _frame = new Border
+ {
+ Content = row,
+ HeightRequest = 40,
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ BackgroundColor = Colors.Transparent
+ };
+ _frame.Token(Border.StrokeProperty, ShellToken.Input);
+ Content = _frame;
+ UpdateText();
+ UpdateButtons();
+ }
+
+ private static BoxView Divider()
+ {
+ var line = new BoxView { BackgroundColor = Colors.Transparent };
+ line.Token(BoxView.ColorProperty, ShellToken.Input);
+ return line;
+ }
+
+ private Grid StepButton(IconName icon, string description, int direction)
+ {
+ var glyph = new Icon { Name = icon, Size = 16, Token = ShellToken.MutedForeground, HorizontalOptions = LayoutOptions.Center, VerticalOptions = LayoutOptions.Center };
+ var button = new Grid { BackgroundColor = Colors.Transparent, Children = { glyph } };
+ SemanticProperties.SetDescription(button, description);
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => { if (button.Opacity > 0.9) { button.Token(VisualElement.BackgroundColorProperty, ShellToken.Muted); glyph.Token = ShellToken.Foreground; } };
+ pointer.PointerExited += (_, _) =>
+ {
+ button.ClearValue(VisualElement.BackgroundColorProperty);
+ button.BackgroundColor = Colors.Transparent;
+ glyph.Token = ShellToken.MutedForeground;
+ };
+ button.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) =>
+ {
+ if (!IsEnabled) return;
+ Commit();
+ Value = (Value ?? Minimum ?? 0) + direction * Step;
+ };
+ button.GestureRecognizers.Add(tap);
+ return button;
+ }
+
+ private decimal? Clamp(decimal? value)
+ {
+ if (value is not { } number) return null;
+ if (Minimum is { } min && number < min) number = min;
+ if (Maximum is { } max && number > max) number = max;
+ return number;
+ }
+
+ // A typed number is clamped and kept, empty clears the value, anything else is reverted.
+ private void Commit()
+ {
+ var text = _entry.Text?.Trim();
+ if (string.IsNullOrEmpty(text)) Value = null;
+ else if (decimal.TryParse(text, NumberStyles.Number, CultureInfo.CurrentCulture, out var typed) ||
+ decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out typed))
+ Value = typed;
+ UpdateText();
+ }
+
+ private void OnLimitsChanged()
+ {
+ Value = Clamp(Value);
+ UpdateButtons();
+ }
+
+ private void OnValueChanged()
+ {
+ UpdateText();
+ UpdateButtons();
+ ValueChanged?.Invoke(this, Value);
+ }
+
+ private void UpdateText()
+ {
+ var text = Value?.ToString(CultureInfo.CurrentCulture) ?? string.Empty;
+ if (_entry.Text != text) _entry.Text = text;
+ }
+
+ private void UpdateButtons()
+ {
+ _decrease.Opacity = Value is { } v && Minimum is { } min && v <= min ? 0.4 : 1;
+ _increase.Opacity = Value is { } w && Maximum is { } max && w >= max ? 0.4 : 1;
+ }
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == IsEnabledProperty.PropertyName && _entry != null)
+ {
+ Opacity = IsEnabled ? 1.0 : 0.5;
+ _entry.IsEnabled = IsEnabled;
+ }
+ }
+}
+"
+ };
+}
diff --git a/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs
index f320450..d1ceec3 100644
--- a/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs
@@ -386,9 +386,9 @@ public static void BlurActiveInput()
}
// Page-level layer for anything that must draw above the page: modal overlays, floating panels,
-// tooltips, toasts — the MAUI take on a React portal. Created on first use as the last child of
-// the page's root Grid (a non-Grid root is wrapped in one, once). The layer itself never takes
-// input; only what is placed in it does.
+// tooltips, toasts — the MAUI take on a React portal. Set up as the page appears: the page content
+// is wrapped in a Grid, once, and the layer is that Grid's last child. The layer itself never
+// takes input; only what is placed in it does.
public static class ShellPortal
{
private static readonly BindableProperty LayerProperty =
@@ -410,20 +410,17 @@ public static class ShellPortal
if (page is null) return null;
if (page.GetValue(LayerProperty) is Grid existing) return existing;
- if (page.Content is not Grid root)
- {
- var content = page.Content;
- var scroll = content as ScrollView;
- var (scrollX, scrollY) = (scroll?.ScrollX ?? 0, scroll?.ScrollY ?? 0);
- // Edge-to-edge like the page itself, so wrapping doesn't change how the content is inset.
- root = new Grid { SafeAreaEdges = SafeAreaEdges.None };
- page.Content = root;
- if (content != null) root.Children.Add(content);
- // Normally this runs as the page appears (see ShellTheme.EnsureInitialized); if it
- // runs later, re-parenting must not lose where the user had scrolled to.
- if (scroll != null && (scrollX > 0 || scrollY > 0))
- scroll.Dispatcher.Dispatch(() => _ = scroll.ScrollToAsync(scrollX, scrollY, false));
- }
+ // Always wrapped, even a Grid root: the wrapper spans the window while the content keeps
+ // its own safe-area insets, so the layer can reach under the system bars.
+ var content = page.Content;
+ var scroll = content as ScrollView;
+ var (scrollX, scrollY) = (scroll?.ScrollX ?? 0, scroll?.ScrollY ?? 0);
+ var root = new Grid { SafeAreaEdges = SafeAreaEdges.None };
+ page.Content = root;
+ if (content != null) root.Children.Add(content);
+ // Re-parenting must not lose the scroll position if this runs after the page appeared.
+ if (scroll != null && (scrollX > 0 || scrollY > 0))
+ scroll.Dispatcher.Dispatch(() => _ = scroll.ScrollToAsync(scrollX, scrollY, false));
// The layer covers the whole window (backdrops dim under the system bars); content placed
// in it uses GetSafeInsets to stay clear of them.
@@ -434,8 +431,6 @@ public static class ShellPortal
CascadeInputTransparent = false,
SafeAreaEdges = SafeAreaEdges.None
};
- Grid.SetRowSpan(layer, Math.Max(1, root.RowDefinitions.Count));
- Grid.SetColumnSpan(layer, Math.Max(1, root.ColumnDefinitions.Count));
root.Children.Add(layer);
page.SetValue(LayerProperty, layer);
#if ANDROID
@@ -631,7 +626,10 @@ public static Point GetPosition(VisualElement view, Grid layer)
panel.BindingContext = (options.Owner ?? anchor).BindingContext;
panel.HorizontalOptions = LayoutOptions.Start;
panel.VerticalOptions = LayoutOptions.Start;
- panel.Margin = new Thickness(0);
+ // Start inside the safe area: a panel first laid out under the status bar gets padded
+ // for it, and keeps that padding after it is moved into place.
+ var inset = GetSafeInsets(layer);
+ panel.Margin = new Thickness(inset.Left + 8, inset.Top + 8, 0, 0);
panel.Opacity = 0;
panel.IsVisible = true;
if (options.MatchAnchorWidth) panel.WidthRequest = anchor.Width;
@@ -651,6 +649,13 @@ private static async Task PlaceAsync(View anchor, View panel, Grid layer, ShellP
var size = new Size(panel.Width, panel.Height);
var origin = GetPosition(anchor, layer);
+ var anchorSize = new Size(anchor.Width, anchor.Height);
+ if (options.AnchorPoint is { } point)
+ {
+ // Open at a point inside the anchor (context menus): a zero-size anchor there.
+ origin = new Point(origin.X + point.X, origin.Y + point.Y);
+ anchorSize = Size.Zero;
+ }
const double edge = 8;
var safe = GetSafeInsets(layer);
var minTop = safe.Top + edge;
@@ -658,11 +663,11 @@ private static async Task PlaceAsync(View anchor, View panel, Grid layer, ShellP
var x = options.Align switch
{
- ShellPopupAlign.Center => origin.X + (anchor.Width - size.Width) / 2,
- ShellPopupAlign.End => origin.X + anchor.Width - size.Width,
+ ShellPopupAlign.Center => origin.X + (anchorSize.Width - size.Width) / 2,
+ ShellPopupAlign.End => origin.X + anchorSize.Width - size.Width,
_ => origin.X
};
- var below = origin.Y + anchor.Height + options.Offset;
+ var below = origin.Y + anchorSize.Height + options.Offset;
var above = origin.Y - size.Height - options.Offset;
var fitsBelow = below + size.Height <= maxBottom;
var fitsAbove = above >= minTop;
@@ -691,6 +696,8 @@ public sealed class ShellPopupOptions
public ShellPopupAlign Align { get; init; } = ShellPopupAlign.Start;
public double Offset { get; init; } = 4;
public bool MatchAnchorWidth { get; init; }
+ // Position inside the anchor to open at instead of its edge (e.g. where the user right-clicked).
+ public Point? AnchorPoint { get; init; }
// Modal popups get a click-outside catcher (menus, selects); tooltips and hover cards don't.
public bool Modal { get; init; } = true;
public Action? OnDismiss { get; init; }
@@ -871,6 +878,8 @@ protected ShellPopoverHost()
protected virtual bool Modal => true;
protected virtual ShellPopupPlacement Placement => ShellPopupPlacement.Bottom;
protected virtual ShellPopupAlign Align => ShellPopupAlign.Start;
+ protected virtual Point? AnchorPoint => null;
+ protected virtual double Offset => 4;
protected View? PopupContent => _content;
@@ -899,6 +908,8 @@ private void OnOpenChanged()
{
Placement = Placement,
Align = Align,
+ Offset = Offset,
+ AnchorPoint = AnchorPoint,
Modal = Modal,
Owner = this,
OnDismiss = Close
diff --git a/src/ShellUI.Native.Templates/Templates/StatCardTemplate.cs b/src/ShellUI.Native.Templates/Templates/StatCardTemplate.cs
new file mode 100644
index 0000000..b3e4fb6
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/StatCardTemplate.cs
@@ -0,0 +1,179 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class StatCardTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "stat-card",
+ DisplayName = "Stat Card",
+ Description = "Dashboard metric with trend and description",
+ Category = ComponentCategory.DataDisplay,
+ FilePath = "StatCard.cs",
+ Dependencies = new List { "shell", "icon" },
+ Tags = new List { "stat", "metric", "kpi", "dashboard" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"using Microsoft.Maui.Controls.Shapes;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Dashboard metric — rounded-lg border bg-card p-5: a muted title with an optional icon, the
+// value in text-2xl bold, a change pill tinted by trend, and an optional description.
+// Usage:
+public partial class StatCard : ContentView
+{
+ public static readonly BindableProperty TitleProperty =
+ BindableProperty.Create(nameof(Title), typeof(string), typeof(StatCard), string.Empty,
+ propertyChanged: (b, o, n) => ((StatCard)b).Update());
+
+ public static readonly BindableProperty ValueProperty =
+ BindableProperty.Create(nameof(Value), typeof(string), typeof(StatCard), string.Empty,
+ propertyChanged: (b, o, n) => ((StatCard)b).Update());
+
+ public static readonly BindableProperty ChangeProperty =
+ BindableProperty.Create(nameof(Change), typeof(string), typeof(StatCard), string.Empty,
+ propertyChanged: (b, o, n) => ((StatCard)b).Update());
+
+ public static readonly BindableProperty TrendProperty =
+ BindableProperty.Create(nameof(Trend), typeof(StatTrend), typeof(StatCard), StatTrend.Neutral,
+ propertyChanged: (b, o, n) => ((StatCard)b).Update());
+
+ public static readonly BindableProperty DescriptionProperty =
+ BindableProperty.Create(nameof(Description), typeof(string), typeof(StatCard), string.Empty,
+ propertyChanged: (b, o, n) => ((StatCard)b).Update());
+
+ public static readonly BindableProperty IconProperty =
+ BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(StatCard), IconName.None,
+ propertyChanged: (b, o, n) => ((StatCard)b).Update());
+
+ public string Title
+ {
+ get => (string)GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public string Value
+ {
+ get => (string)GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public string Change
+ {
+ get => (string)GetValue(ChangeProperty);
+ set => SetValue(ChangeProperty, value);
+ }
+
+ public StatTrend Trend
+ {
+ get => (StatTrend)GetValue(TrendProperty);
+ set => SetValue(TrendProperty, value);
+ }
+
+ public string Description
+ {
+ get => (string)GetValue(DescriptionProperty);
+ set => SetValue(DescriptionProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => (IconName)GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ private readonly Label _title;
+ private readonly Icon _icon;
+ private readonly Label _value;
+ private readonly Border _pill;
+ private readonly BoxView _pillTint;
+ private readonly Label _change;
+ private readonly Label _description;
+
+ public StatCard()
+ {
+ _title = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold, VerticalTextAlignment = TextAlignment.Center, LineBreakMode = LineBreakMode.TailTruncation };
+ _title.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+ _icon = new Icon { Size = 16, Token = ShellToken.MutedForeground, VerticalOptions = LayoutOptions.Center };
+ var header = new Grid
+ {
+ ColumnSpacing = 8,
+ ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto) }
+ };
+ 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);
+
+ // The pill is the trend color at low strength behind text in the full color.
+ _pillTint = new BoxView { BackgroundColor = Colors.Transparent };
+ _change = new Label { FontSize = 12, FontAttributes = FontAttributes.Bold, Margin = new Thickness(6, 2), VerticalTextAlignment = TextAlignment.Center };
+ _pill = new Border
+ {
+ Content = new Grid { Children = { _pillTint, _change } },
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ VerticalOptions = LayoutOptions.Center,
+ BackgroundColor = Colors.Transparent
+ };
+
+ _description = new Label { FontSize = 12 };
+ _description.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+
+ var card = new Border
+ {
+ Content = new VerticalStackLayout
+ {
+ Spacing = 0,
+ Children =
+ {
+ header,
+ new HorizontalStackLayout { Spacing = 8, Margin = new Thickness(0, 8, 0, 0), Children = { _value, _pill } },
+ _description
+ }
+ },
+ 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;
+ Update();
+ }
+
+ private void Update()
+ {
+ _title.Text = Title ?? string.Empty;
+ _icon.Name = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ _value.Text = Value ?? string.Empty;
+
+ _change.Text = Change ?? string.Empty;
+ _pill.IsVisible = !string.IsNullOrEmpty(Change);
+ var (tint, strength, text) = Trend switch
+ {
+ StatTrend.Up => (ShellToken.Success, 0.12, ShellToken.Success),
+ StatTrend.Down => (ShellToken.Destructive, 0.12, ShellToken.Destructive),
+ _ => (ShellToken.Muted, 1.0, ShellToken.MutedForeground)
+ };
+ _pillTint.Token(BoxView.ColorProperty, tint);
+ _pillTint.Opacity = strength;
+ _change.Token(Label.TextColorProperty, text);
+
+ _description.Text = Description ?? string.Empty;
+ _description.IsVisible = !string.IsNullOrEmpty(Description);
+ _description.Margin = new Thickness(0, 4, 0, 0);
+ }
+}
+
+public enum StatTrend { Neutral, Up, Down }
+"
+ };
+}
diff --git a/src/ShellUI.Native.Templates/Templates/StepperTemplate.cs b/src/ShellUI.Native.Templates/Templates/StepperTemplate.cs
new file mode 100644
index 0000000..816f485
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/StepperTemplate.cs
@@ -0,0 +1,301 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class StepperTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "stepper",
+ DisplayName = "Stepper",
+ Description = "Step-by-step flow with numbered steps and navigation",
+ Category = ComponentCategory.Navigation,
+ FilePath = "Stepper.cs",
+ Dependencies = new List { "shell", "icon", "button" },
+ Tags = new List { "stepper", "wizard", "steps", "progress" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"using System.Collections.ObjectModel;
+using Microsoft.Maui.Controls.Shapes;
+using YourProjectNamespace.Components.UI.Variants;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Step-by-step flow — numbered circles joined by lines (active filled, completed checked), the
+// active step's content, and Previous / Next / Confirm buttons. A step becomes clickable once it
+// has been reached.
+//
+// ...any view...
+// ...
+// ...
+//
+[ContentProperty(nameof(Steps))]
+public partial class Stepper : ContentView
+{
+ public static readonly BindableProperty CurrentStepProperty =
+ BindableProperty.Create(nameof(CurrentStep), typeof(int), typeof(Stepper), 0, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((Stepper)b).OnCurrentStepChanged());
+
+ public static readonly BindableProperty ShowNavigationProperty =
+ BindableProperty.Create(nameof(ShowNavigation), typeof(bool), typeof(Stepper), true,
+ propertyChanged: (b, o, n) => ((Stepper)b).UpdateState());
+
+ public static readonly BindableProperty PreviousTextProperty =
+ BindableProperty.Create(nameof(PreviousText), typeof(string), typeof(Stepper), ""Previous"",
+ propertyChanged: (b, o, n) => ((Stepper)b).UpdateState());
+
+ public static readonly BindableProperty NextTextProperty =
+ BindableProperty.Create(nameof(NextText), typeof(string), typeof(Stepper), ""Next"",
+ propertyChanged: (b, o, n) => ((Stepper)b).UpdateState());
+
+ public static readonly BindableProperty ConfirmTextProperty =
+ BindableProperty.Create(nameof(ConfirmText), typeof(string), typeof(Stepper), ""Confirm"",
+ propertyChanged: (b, o, n) => ((Stepper)b).UpdateState());
+
+ public int CurrentStep
+ {
+ get => (int)GetValue(CurrentStepProperty);
+ set => SetValue(CurrentStepProperty, value);
+ }
+
+ public bool ShowNavigation
+ {
+ get => (bool)GetValue(ShowNavigationProperty);
+ set => SetValue(ShowNavigationProperty, value);
+ }
+
+ public string PreviousText
+ {
+ get => (string)GetValue(PreviousTextProperty);
+ set => SetValue(PreviousTextProperty, value);
+ }
+
+ public string NextText
+ {
+ get => (string)GetValue(NextTextProperty);
+ set => SetValue(NextTextProperty, value);
+ }
+
+ public string ConfirmText
+ {
+ get => (string)GetValue(ConfirmTextProperty);
+ set => SetValue(ConfirmTextProperty, value);
+ }
+
+ public IList Steps => _steps;
+
+ public event EventHandler? CurrentStepChanged;
+ // Raised by the Confirm button on the last step.
+ public event EventHandler? Confirmed;
+
+ private const double CircleSize = 32;
+
+ private sealed class Marker(Border circle, Label number, Icon check, Label title, BoxView before, BoxView after)
+ {
+ public Border Circle { get; } = circle;
+ public Label Number { get; } = number;
+ public Icon Check { get; } = check;
+ public Label Title { get; } = title;
+ public BoxView Before { get; } = before;
+ public BoxView After { get; } = after;
+ }
+
+ private readonly ObservableCollection _steps = new();
+ private readonly List _markers = new();
+ private readonly Grid _header;
+ private readonly ContentView _body;
+ private readonly Grid _navigation;
+ private readonly Button _previous;
+ private readonly Button _next;
+ private int _highest;
+
+ public Stepper()
+ {
+ _header = new Grid();
+ _body = new ContentView { Margin = new Thickness(0, 24, 0, 0) };
+
+ _previous = new Button { Variant = ButtonVariant.Outline, HorizontalOptions = LayoutOptions.Start };
+ _previous.Clicked += (_, _) => Previous();
+ _next = new Button { HorizontalOptions = LayoutOptions.End };
+ _next.Clicked += (_, _) =>
+ {
+ if (IsLast)
+ {
+ _highest = Math.Max(_highest, _steps.Count - 1);
+ Confirmed?.Invoke(this, EventArgs.Empty);
+ }
+ else Next();
+ };
+ _navigation = new Grid { Margin = new Thickness(0, 24, 0, 0), Children = { _previous, _next } };
+
+ Content = new VerticalStackLayout { Spacing = 0, Children = { _header, _body, _navigation } };
+ _steps.CollectionChanged += (_, _) => Rebuild();
+ Rebuild();
+ }
+
+ private int Current => Math.Clamp(CurrentStep, 0, Math.Max(0, _steps.Count - 1));
+ private bool IsLast => Current >= _steps.Count - 1;
+
+ public void Next()
+ {
+ if (!IsLast) CurrentStep = Current + 1;
+ }
+
+ public void Previous()
+ {
+ if (Current > 0) CurrentStep = Current - 1;
+ }
+
+ private void OnCurrentStepChanged()
+ {
+ _highest = Math.Max(_highest, Current);
+ UpdateState();
+ CurrentStepChanged?.Invoke(this, Current);
+ }
+
+ private void Rebuild()
+ {
+ _header.Children.Clear();
+ _header.ColumnDefinitions.Clear();
+ _markers.Clear();
+
+ for (var i = 0; i < _steps.Count; i++)
+ {
+ var index = i;
+ var step = _steps[i];
+ step.PropertyChanged -= OnStepPropertyChanged;
+ step.PropertyChanged += OnStepPropertyChanged;
+
+ var number = new Label
+ {
+ Text = (i + 1).ToString(),
+ FontSize = 14,
+ FontAttributes = FontAttributes.Bold,
+ HorizontalTextAlignment = TextAlignment.Center,
+ VerticalTextAlignment = TextAlignment.Center
+ };
+ var check = new Icon { Name = IconName.Check, Size = 16, Token = ShellToken.Primary, HorizontalOptions = LayoutOptions.Center, VerticalOptions = LayoutOptions.Center };
+ var circle = new Border
+ {
+ Content = new Grid { Children = { number, check } },
+ WidthRequest = CircleSize,
+ HeightRequest = CircleSize,
+ StrokeThickness = 2,
+ StrokeShape = new RoundRectangle { CornerRadius = CircleSize / 2 }
+ };
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { if (index <= _highest) CurrentStep = index; };
+ circle.GestureRecognizers.Add(tap);
+
+ // Lines run from each circle to the edges of its column, so neighbors meet.
+ var before = Line(i > 0);
+ var after = Line(i < _steps.Count - 1);
+ var rail = new Grid
+ {
+ ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star) }
+ };
+ rail.Add(before, 0, 0);
+ rail.Add(circle, 1, 0);
+ rail.Add(after, 2, 0);
+
+ var title = new Label
+ {
+ Text = step.Title,
+ FontSize = 14,
+ FontAttributes = FontAttributes.Bold,
+ HorizontalTextAlignment = TextAlignment.Center,
+ Margin = new Thickness(4, 8, 4, 0),
+ IsVisible = !string.IsNullOrEmpty(step.Title)
+ };
+ var description = new Label
+ {
+ Text = step.Description,
+ FontSize = 12,
+ HorizontalTextAlignment = TextAlignment.Center,
+ Margin = new Thickness(4, 2, 4, 0),
+ IsVisible = !string.IsNullOrEmpty(step.Description)
+ };
+ description.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+
+ _header.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
+ _header.Add(new VerticalStackLayout { Spacing = 0, Children = { rail, title, description } }, i, 0);
+ _markers.Add(new Marker(circle, number, check, title, before, after));
+ }
+
+ _highest = Math.Min(_highest, Math.Max(0, _steps.Count - 1));
+ UpdateState();
+
+ static BoxView Line(bool visible) => new()
+ {
+ HeightRequest = 2,
+ BackgroundColor = Colors.Transparent,
+ VerticalOptions = LayoutOptions.Center,
+ Opacity = visible ? 1 : 0
+ };
+ }
+
+ private void OnStepPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName is nameof(StepperStep.Title) or nameof(StepperStep.Description)) Rebuild();
+ }
+
+ private void UpdateState()
+ {
+ var current = Current;
+ for (var i = 0; i < _markers.Count; i++)
+ {
+ var marker = _markers[i];
+ var active = i == current;
+ var completed = i < _highest && !active;
+
+ if (active) marker.Circle.Token(VisualElement.BackgroundColorProperty, ShellToken.Primary);
+ else
+ {
+ marker.Circle.ClearValue(VisualElement.BackgroundColorProperty);
+ marker.Circle.BackgroundColor = Colors.Transparent;
+ }
+ marker.Circle.Token(Border.StrokeProperty, active || completed ? ShellToken.Primary : ShellToken.Border);
+ marker.Number.IsVisible = !completed;
+ marker.Number.Token(Label.TextColorProperty, active ? ShellToken.PrimaryForeground : ShellToken.MutedForeground);
+ marker.Check.IsVisible = completed;
+ marker.Title.Token(Label.TextColorProperty, active || completed ? ShellToken.Foreground : ShellToken.MutedForeground);
+ marker.Before.Token(BoxView.ColorProperty, i <= _highest ? ShellToken.Primary : ShellToken.Border);
+ marker.After.Token(BoxView.ColorProperty, i < _highest ? ShellToken.Primary : ShellToken.Border);
+ }
+
+ _body.Content = _steps.Count > 0 ? _steps[current] : null;
+
+ _navigation.IsVisible = ShowNavigation && _steps.Count > 0;
+ _previous.Text = PreviousText;
+ _previous.IsEnabled = current > 0;
+ _next.Text = IsLast ? ConfirmText : NextText;
+ }
+}
+
+// One step of a Stepper: its Title and Description label the step, its content shows while the
+// step is active.
+public partial class StepperStep : ContentView
+{
+ public static readonly BindableProperty TitleProperty =
+ BindableProperty.Create(nameof(Title), typeof(string), typeof(StepperStep), string.Empty);
+
+ public static readonly BindableProperty DescriptionProperty =
+ BindableProperty.Create(nameof(Description), typeof(string), typeof(StepperStep), string.Empty);
+
+ public string Title
+ {
+ get => (string)GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public string Description
+ {
+ get => (string)GetValue(DescriptionProperty);
+ set => SetValue(DescriptionProperty, value);
+ }
+}
+"
+ };
+}
diff --git a/src/ShellUI.Native.Templates/Templates/TableTemplate.cs b/src/ShellUI.Native.Templates/Templates/TableTemplate.cs
new file mode 100644
index 0000000..e3d8b3f
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/TableTemplate.cs
@@ -0,0 +1,364 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class TableTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "table",
+ DisplayName = "Table",
+ Description = "Data table with header, rows, hover and optional caption",
+ Category = ComponentCategory.DataDisplay,
+ FilePath = "Table.cs",
+ Dependencies = new List { "shell" },
+ Tags = new List { "table", "data", "grid", "rows" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"using Microsoft.Maui.Controls.Shapes;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Data table — text-sm rows inside a rounded-md border: a header row in the muted foreground,
+// rows divided by border-b that tint on hover, optional caption.
+//
+//
+// ...
+//
+//
+// ...
+//
+//
+// Columns takes Grid column widths. Use star and fixed widths: every row is its own grid, so an
+// Auto column would size per row. For long lists, use TableRow (with its own Columns) as the item
+// template of a CollectionView under a TableHeader.
+[ContentProperty(nameof(Rows))]
+public partial class Table : ContentView
+{
+ public static readonly BindableProperty ColumnsProperty =
+ BindableProperty.Create(nameof(Columns), typeof(string), typeof(Table), string.Empty,
+ propertyChanged: (b, o, n) => ((Table)b).Refresh());
+
+ public static readonly BindableProperty CaptionProperty =
+ BindableProperty.Create(nameof(Caption), typeof(string), typeof(Table), string.Empty,
+ propertyChanged: (b, o, n) => ((Table)b).UpdateChrome());
+
+ public static readonly BindableProperty BorderedProperty =
+ BindableProperty.Create(nameof(Bordered), typeof(bool), typeof(Table), true,
+ propertyChanged: (b, o, n) => ((Table)b).UpdateChrome());
+
+ // Narrower than this, the table scrolls horizontally instead of squeezing its columns.
+ public static readonly BindableProperty MinimumContentWidthProperty =
+ BindableProperty.Create(nameof(MinimumContentWidth), typeof(double), typeof(Table), 0d,
+ propertyChanged: (b, o, n) => ((Table)b).UpdateScrolling());
+
+ public string Columns
+ {
+ get => (string)GetValue(ColumnsProperty);
+ set => SetValue(ColumnsProperty, value);
+ }
+
+ public string Caption
+ {
+ get => (string)GetValue(CaptionProperty);
+ set => SetValue(CaptionProperty, value);
+ }
+
+ public bool Bordered
+ {
+ get => (bool)GetValue(BorderedProperty);
+ set => SetValue(BorderedProperty, value);
+ }
+
+ public double MinimumContentWidth
+ {
+ get => (double)GetValue(MinimumContentWidthProperty);
+ set => SetValue(MinimumContentWidthProperty, value);
+ }
+
+ public IList Rows => _rows.Children;
+
+ private readonly VerticalStackLayout _rows;
+ private readonly ScrollView _scroll;
+ private readonly Border _frame;
+ private readonly Label _caption;
+
+ public Table()
+ {
+ _rows = new VerticalStackLayout { Spacing = 0 };
+ _rows.ChildAdded += (_, _) => Refresh();
+ _rows.ChildRemoved += (_, _) => Refresh();
+ _scroll = new ScrollView { Orientation = ScrollOrientation.Horizontal };
+
+ _frame = new Border
+ {
+ Content = _rows,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ BackgroundColor = Colors.Transparent
+ };
+ _frame.Token(Border.StrokeProperty, ShellToken.Border);
+ _frame.SizeChanged += (_, _) => FitScrollContent();
+
+ _caption = new Label { FontSize = 14, HorizontalTextAlignment = TextAlignment.Center, Margin = new Thickness(0, 12, 0, 0) };
+ _caption.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+
+ Content = new VerticalStackLayout { Spacing = 0, Children = { _frame, _caption } };
+ Loaded += (_, _) => Refresh();
+ UpdateChrome();
+ }
+
+ // Parses ""2*,*,100"" into column widths. Empty means equal star columns.
+ internal static IReadOnlyList ParseColumns(string? columns)
+ {
+ if (string.IsNullOrWhiteSpace(columns)) return Array.Empty();
+ return columns.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(ParseWidth)
+ .ToList();
+
+ static GridLength ParseWidth(string part)
+ {
+ var invariant = System.Globalization.CultureInfo.InvariantCulture;
+ if (part.Equals(""auto"", StringComparison.OrdinalIgnoreCase)) return GridLength.Auto;
+ if (part.EndsWith('*'))
+ {
+ var factor = part[..^1];
+ return new GridLength(factor.Length > 0 && double.TryParse(factor, System.Globalization.NumberStyles.Float, invariant, out var stars) ? stars : 1, GridUnitType.Star);
+ }
+ return double.TryParse(part, System.Globalization.NumberStyles.Float, invariant, out var width) ? new GridLength(width) : GridLength.Star;
+ }
+ }
+
+ // The table hands rows their columns and tells the last one to drop its divider.
+ private void Refresh()
+ {
+ var widths = ParseColumns(Columns);
+ var rows = _rows.Children.OfType().ToList();
+ for (var i = 0; i < rows.Count; i++)
+ {
+ if (string.IsNullOrWhiteSpace(rows[i].Columns)) rows[i].Apply(widths);
+ rows[i].ShowDivider = i < rows.Count - 1;
+ }
+ }
+
+ private void UpdateChrome()
+ {
+ _frame.StrokeThickness = Bordered ? 1 : 0;
+ _caption.Text = Caption ?? string.Empty;
+ _caption.IsVisible = !string.IsNullOrEmpty(Caption);
+ }
+
+ private void UpdateScrolling()
+ {
+ var scrolls = MinimumContentWidth > 0;
+ if (scrolls && _frame.Content != _scroll)
+ {
+ _frame.Content = null;
+ _scroll.Content = _rows;
+ _frame.Content = _scroll;
+ }
+ else if (!scrolls && _frame.Content != _rows)
+ {
+ _scroll.Content = null;
+ _frame.Content = _rows;
+ _rows.WidthRequest = -1;
+ }
+ FitScrollContent();
+ }
+
+ // Inside a horizontal ScrollView rows measure at their minimum width; give them a real one.
+ private void FitScrollContent()
+ {
+ if (MinimumContentWidth <= 0 || _frame.Width <= 0) return;
+ _rows.WidthRequest = Math.Max(_frame.Width - 2 * _frame.StrokeThickness, MinimumContentWidth);
+ }
+}
+
+// Body row — border-b, hover:bg-muted/50, bg-muted when selected. Its children are the cells,
+// placed in columns in order.
+public partial class TableRow : Grid
+{
+ // Set only for rows used outside a Table (e.g. a CollectionView item template).
+ public static readonly BindableProperty ColumnsProperty =
+ BindableProperty.Create(nameof(Columns), typeof(string), typeof(TableRow), string.Empty,
+ propertyChanged: (b, o, n) => ((TableRow)b).Apply(Table.ParseColumns((string)n)));
+
+ public static readonly BindableProperty IsSelectedProperty =
+ BindableProperty.Create(nameof(IsSelected), typeof(bool), typeof(TableRow), false,
+ propertyChanged: (b, o, n) => ((TableRow)b).UpdateFill());
+
+ public string Columns
+ {
+ get => (string)GetValue(ColumnsProperty);
+ set => SetValue(ColumnsProperty, value);
+ }
+
+ public bool IsSelected
+ {
+ get => (bool)GetValue(IsSelectedProperty);
+ set => SetValue(IsSelectedProperty, value);
+ }
+
+ public event EventHandler? Tapped;
+
+ private readonly BoxView _fill;
+ private readonly BoxView _divider;
+ private IReadOnlyList _widths = Array.Empty();
+ private bool _hovered;
+
+ internal bool ShowDivider
+ {
+ get => _divider.IsVisible;
+ set => _divider.IsVisible = value;
+ }
+
+ protected virtual bool Interactive => true;
+
+ public TableRow()
+ {
+ _fill = new BoxView { BackgroundColor = Colors.Transparent, Opacity = 0, InputTransparent = true, ZIndex = -1 };
+ _fill.Token(BoxView.ColorProperty, ShellToken.Muted);
+ _divider = new BoxView { BackgroundColor = Colors.Transparent, HeightRequest = 1, VerticalOptions = LayoutOptions.End, InputTransparent = true };
+ _divider.Token(BoxView.ColorProperty, ShellToken.Border);
+ Children.Add(_fill);
+ Children.Add(_divider);
+ // A layout with no background only takes pointer input over its children.
+ BackgroundColor = Colors.Transparent;
+
+ if (Interactive)
+ {
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => { _hovered = true; UpdateFill(); };
+ pointer.PointerExited += (_, _) => { _hovered = false; UpdateFill(); };
+ GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => Tapped?.Invoke(this, EventArgs.Empty);
+ GestureRecognizers.Add(tap);
+ }
+ }
+
+ protected override void OnChildAdded(Element child)
+ {
+ base.OnChildAdded(child);
+ // _divider is still null while the constructor adds the row's own chrome.
+ if (_divider != null && child != _fill && child != _divider) Apply(_widths);
+ }
+
+ internal void Apply(IReadOnlyList widths)
+ {
+ _widths = widths;
+ var cells = Children.Where(c => c != _fill && c != _divider).ToList();
+ var count = Math.Max(1, Math.Max(cells.Count, widths.Count));
+
+ ColumnDefinitions.Clear();
+ for (var i = 0; i < count; i++)
+ ColumnDefinitions.Add(new ColumnDefinition(i < widths.Count ? widths[i] : GridLength.Star));
+ for (var i = 0; i < cells.Count; i++)
+ Grid.SetColumn((BindableObject)cells[i], i);
+ Grid.SetColumnSpan(_fill, count);
+ Grid.SetColumnSpan(_divider, count);
+ }
+
+ private void UpdateFill() => _fill.Opacity = IsSelected ? 1 : _hovered ? 0.5 : 0;
+}
+
+// Header row: holds TableHead cells, no hover.
+public partial class TableHeader : TableRow
+{
+ protected override bool Interactive => false;
+}
+
+// Header cell — h-11 px-4 font-medium text-muted-foreground.
+public partial class TableHead : ContentView
+{
+ public static readonly BindableProperty TextProperty =
+ BindableProperty.Create(nameof(Text), typeof(string), typeof(TableHead), string.Empty,
+ propertyChanged: (b, o, n) => ((TableHead)b)._label.Text = n as string ?? string.Empty);
+
+ public static readonly BindableProperty HorizontalTextAlignmentProperty =
+ BindableProperty.Create(nameof(HorizontalTextAlignment), typeof(TextAlignment), typeof(TableHead), TextAlignment.Start,
+ propertyChanged: (b, o, n) => ((TableHead)b)._label.HorizontalTextAlignment = (TextAlignment)n);
+
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public TextAlignment HorizontalTextAlignment
+ {
+ get => (TextAlignment)GetValue(HorizontalTextAlignmentProperty);
+ set => SetValue(HorizontalTextAlignmentProperty, value);
+ }
+
+ private readonly Label _label;
+
+ public TableHead()
+ {
+ _label = new Label
+ {
+ FontSize = 14,
+ FontAttributes = FontAttributes.Bold,
+ VerticalTextAlignment = TextAlignment.Center,
+ LineBreakMode = LineBreakMode.TailTruncation
+ };
+ _label.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+ HeightRequest = 44;
+ Padding = new Thickness(16, 0);
+ Content = _label;
+ }
+}
+
+// Body cell — p-4 text-sm. Set Text, or put any view inside.
+public partial class TableCell : ContentView
+{
+ public static readonly BindableProperty TextProperty =
+ BindableProperty.Create(nameof(Text), typeof(string), typeof(TableCell), string.Empty,
+ propertyChanged: (b, o, n) => ((TableCell)b)._label.Text = n as string ?? string.Empty);
+
+ public static readonly BindableProperty HorizontalTextAlignmentProperty =
+ BindableProperty.Create(nameof(HorizontalTextAlignment), typeof(TextAlignment), typeof(TableCell), TextAlignment.Start,
+ propertyChanged: (b, o, n) => ((TableCell)b)._label.HorizontalTextAlignment = (TextAlignment)n);
+
+ public static readonly BindableProperty IsBoldProperty =
+ BindableProperty.Create(nameof(IsBold), typeof(bool), typeof(TableCell), false,
+ propertyChanged: (b, o, n) => ((TableCell)b)._label.FontAttributes = (bool)n ? FontAttributes.Bold : FontAttributes.None);
+
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public TextAlignment HorizontalTextAlignment
+ {
+ get => (TextAlignment)GetValue(HorizontalTextAlignmentProperty);
+ set => SetValue(HorizontalTextAlignmentProperty, value);
+ }
+
+ public bool IsBold
+ {
+ get => (bool)GetValue(IsBoldProperty);
+ set => SetValue(IsBoldProperty, value);
+ }
+
+ private readonly Label _label;
+
+ public TableCell()
+ {
+ _label = new Label
+ {
+ FontSize = 14,
+ VerticalTextAlignment = TextAlignment.Center,
+ VerticalOptions = LayoutOptions.Center,
+ LineBreakMode = LineBreakMode.TailTruncation
+ };
+ _label.Token(Label.TextColorProperty, ShellToken.Foreground);
+ Padding = new Thickness(16, 12);
+ Content = _label;
+ }
+}
+"
+ };
+}
diff --git a/src/ShellUI.Native.Templates/Templates/TagInputTemplate.cs b/src/ShellUI.Native.Templates/Templates/TagInputTemplate.cs
new file mode 100644
index 0000000..a381dcb
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/TagInputTemplate.cs
@@ -0,0 +1,240 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class TagInputTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "tag-input",
+ DisplayName = "Tag Input",
+ Description = "Text field that turns entries into removable tags",
+ Category = ComponentCategory.Form,
+ FilePath = "TagInput.cs",
+ Dependencies = new List { "shell", "icon", "wrap-layout" },
+ Tags = new List { "tags", "chips", "input", "labels" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"using System.Collections.Specialized;
+using Microsoft.Maui.Controls.Shapes;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Tag field — min-h-10 rounded-md border holding chips (rounded-md bg-secondary text-xs) and a
+// text field that wraps with them. Enter, a comma or a semicolon turns what was typed into a
+// tag; the × on a chip removes it.
+// Usage:
+public partial class TagInput : ContentView
+{
+ public static readonly BindableProperty TagsProperty =
+ BindableProperty.Create(nameof(Tags), typeof(IList), typeof(TagInput), null, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((TagInput)b).OnTagsReplaced(o, n));
+
+ public static readonly BindableProperty PlaceholderProperty =
+ BindableProperty.Create(nameof(Placeholder), typeof(string), typeof(TagInput), ""Add tag..."",
+ propertyChanged: (b, o, n) => ((TagInput)b).UpdatePlaceholder());
+
+ // 0 means no limit.
+ public static readonly BindableProperty MaxTagsProperty =
+ BindableProperty.Create(nameof(MaxTags), typeof(int), typeof(TagInput), 0);
+
+ public static readonly BindableProperty AllowDuplicatesProperty =
+ BindableProperty.Create(nameof(AllowDuplicates), typeof(bool), typeof(TagInput), false);
+
+ public IList? Tags
+ {
+ get => (IList?)GetValue(TagsProperty);
+ set => SetValue(TagsProperty, value);
+ }
+
+ public string Placeholder
+ {
+ get => (string)GetValue(PlaceholderProperty);
+ set => SetValue(PlaceholderProperty, value);
+ }
+
+ public int MaxTags
+ {
+ get => (int)GetValue(MaxTagsProperty);
+ set => SetValue(MaxTagsProperty, value);
+ }
+
+ public bool AllowDuplicates
+ {
+ get => (bool)GetValue(AllowDuplicatesProperty);
+ set => SetValue(AllowDuplicatesProperty, value);
+ }
+
+ public event EventHandler>? TagsChanged;
+
+ private static readonly char[] Separators = { ',', ';', '\n', '\t' };
+
+ private readonly Border _frame;
+ private readonly WrapLayout _flow;
+ private readonly Entry _entry;
+
+ public TagInput()
+ {
+ _entry = new Entry
+ {
+ FontSize = 14,
+ BackgroundColor = Colors.Transparent,
+ HeightRequest = 28,
+ MinimumHeightRequest = 28, // Android gives text fields a 44dp minimum otherwise
+ Margin = new Thickness(2, 2),
+ ClearButtonVisibility = ClearButtonVisibility.Never,
+ IsTextPredictionEnabled = false
+ };
+ _entry.Token(Entry.TextColorProperty, ShellToken.Foreground);
+ _entry.Token(Entry.PlaceholderColorProperty, ShellToken.MutedForeground);
+ ShellPlatform.StripNativeChrome(_entry);
+ ShellFocus.Track(_entry);
+ _entry.Completed += (_, _) =>
+ {
+ AddFromEntry();
+ KeepTyping();
+ };
+ _entry.TextChanged += (_, e) =>
+ {
+ // Typing or pasting a separator commits everything before it.
+ if (e.NewTextValue?.IndexOfAny(Separators) >= 0) AddFromEntry();
+ };
+ _entry.Focused += (_, _) => _frame!.Token(Border.StrokeProperty, ShellToken.Ring);
+ _entry.Unfocused += (_, _) => _frame!.Token(Border.StrokeProperty, ShellToken.Input);
+
+ _flow = new WrapLayout { Spacing = 0, LineSpacing = 0, LastChildFill = true, Children = { _entry } };
+
+ _frame = new Border
+ {
+ Content = _flow,
+ MinimumHeightRequest = 40,
+ Padding = new Thickness(8, 3),
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ BackgroundColor = Colors.Transparent
+ };
+ _frame.Token(Border.StrokeProperty, ShellToken.Input);
+ // Tapping the empty part of the field focuses the text entry.
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { if (IsEnabled) _entry.Focus(); };
+ _frame.GestureRecognizers.Add(tap);
+
+ Content = _frame;
+ UpdatePlaceholder();
+ }
+
+ private IReadOnlyList Current => (IReadOnlyList?)Tags?.ToList() ?? Array.Empty();
+
+ public void Add(string tag)
+ {
+ var text = tag.Trim();
+ if (text.Length == 0) return;
+ var current = Current;
+ if (MaxTags > 0 && current.Count >= MaxTags) return;
+ if (!AllowDuplicates && current.Contains(text, StringComparer.OrdinalIgnoreCase)) return;
+ // A new list each time, so a two-way binding sees the change.
+ Tags = new List(current) { text };
+ }
+
+ public void Remove(string tag)
+ {
+ var next = new List(Current);
+ if (next.Remove(tag)) Tags = next;
+ }
+
+ // Enter dismisses the soft keyboard; stay in the field so tags can be entered in a row.
+ private void KeepTyping()
+ {
+ Dispatcher.Dispatch(async () =>
+ {
+ try
+ {
+ _entry.Focus();
+ await _entry.ShowSoftInputAsync(CancellationToken.None);
+ }
+ catch (Exception)
+ {
+ // No soft keyboard here, or the field is gone.
+ }
+ });
+ }
+
+ private void AddFromEntry()
+ {
+ var text = _entry.Text ?? string.Empty;
+ _entry.Text = string.Empty;
+ foreach (var part in text.Split(Separators, StringSplitOptions.RemoveEmptyEntries))
+ Add(part);
+ }
+
+ private void OnTagsReplaced(object? oldValue, object? newValue)
+ {
+ if (oldValue is INotifyCollectionChanged before) before.CollectionChanged -= OnTagsMutated;
+ if (newValue is INotifyCollectionChanged after) after.CollectionChanged += OnTagsMutated;
+ OnTagsMutated(this, null);
+ }
+
+ private void OnTagsMutated(object? sender, NotifyCollectionChangedEventArgs? e)
+ {
+ Rebuild();
+ TagsChanged?.Invoke(this, Current);
+ }
+
+ private void Rebuild()
+ {
+ foreach (var chip in _flow.Children.Where(c => c != _entry).ToList())
+ _flow.Children.Remove(chip);
+ var index = 0;
+ foreach (var tag in Current)
+ _flow.Children.Insert(index++, CreateChip(tag));
+ UpdatePlaceholder();
+ }
+
+ private View CreateChip(string tag)
+ {
+ var label = new Label { Text = tag, FontSize = 12, FontAttributes = FontAttributes.Bold, VerticalTextAlignment = TextAlignment.Center, LineBreakMode = LineBreakMode.TailTruncation };
+ label.Token(Label.TextColorProperty, ShellToken.SecondaryForeground);
+ var close = new Icon { Name = IconName.X, Size = 12, Token = ShellToken.MutedForeground };
+ // 12px icon in a 20px tap target.
+ var remove = new Grid { WidthRequest = 20, HeightRequest = 20, BackgroundColor = Colors.Transparent, Children = { close } };
+ close.HorizontalOptions = LayoutOptions.Center;
+ close.VerticalOptions = LayoutOptions.Center;
+ SemanticProperties.SetDescription(remove, $""Remove {tag}"");
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => close.Token = ShellToken.Destructive;
+ pointer.PointerExited += (_, _) => close.Token = ShellToken.MutedForeground;
+ remove.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { if (IsEnabled) Remove(tag); };
+ remove.GestureRecognizers.Add(tap);
+
+ var chip = new Border
+ {
+ Content = new HorizontalStackLayout { Spacing = 2, Children = { label, remove } },
+ Padding = new Thickness(8, 0, 2, 0),
+ HeightRequest = 24,
+ Margin = new Thickness(2, 3),
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }
+ };
+ chip.Token(VisualElement.BackgroundColorProperty, ShellToken.Secondary);
+ return chip;
+ }
+
+ private void UpdatePlaceholder() => _entry.Placeholder = Current.Count == 0 ? Placeholder : string.Empty;
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == IsEnabledProperty.PropertyName && _entry != null)
+ {
+ Opacity = IsEnabled ? 1.0 : 0.5;
+ _entry.IsEnabled = IsEnabled;
+ }
+ }
+}
+"
+ };
+}
diff --git a/src/ShellUI.Native.Templates/Templates/TimelineTemplate.cs b/src/ShellUI.Native.Templates/Templates/TimelineTemplate.cs
new file mode 100644
index 0000000..e97169f
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/TimelineTemplate.cs
@@ -0,0 +1,242 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class TimelineTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "timeline",
+ DisplayName = "Timeline",
+ Description = "Vertical list of events joined by a line",
+ Category = ComponentCategory.DataDisplay,
+ FilePath = "Timeline.cs",
+ Dependencies = new List { "shell", "icon" },
+ Tags = new List { "timeline", "history", "activity", "steps" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"using Microsoft.Maui.Controls.Shapes;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Vertical timeline — markers joined by a hairline, each with a title, a time and details.
+//
+//
+//
+//
+//
+//
+[ContentProperty(nameof(Items))]
+public partial class Timeline : ContentView
+{
+ private readonly VerticalStackLayout _stack;
+
+ public IList Items => _stack.Children;
+
+ public Timeline()
+ {
+ _stack = new VerticalStackLayout { Spacing = 0 };
+ _stack.ChildAdded += (_, _) => Refresh();
+ _stack.ChildRemoved += (_, _) => Refresh();
+ Content = _stack;
+ Loaded += (_, _) => Refresh();
+ }
+
+ // The timeline tells each item if it is first or last, so the line starts and stops at a marker.
+ private void Refresh()
+ {
+ var items = _stack.Children.OfType().ToList();
+ for (var i = 0; i < items.Count; i++)
+ items[i].Place(isFirst: i == 0, isLast: i == items.Count - 1);
+ }
+}
+
+// One entry of a Timeline. Without an Icon the marker is a dot (ringed when active); with one it
+// is a 28px circle around the icon.
+[ContentProperty(nameof(Body))]
+public partial class TimelineItem : ContentView
+{
+ public static readonly BindableProperty TitleProperty =
+ BindableProperty.Create(nameof(Title), typeof(string), typeof(TimelineItem), string.Empty,
+ propertyChanged: (b, o, n) => ((TimelineItem)b).Update());
+
+ public static readonly BindableProperty TimeProperty =
+ BindableProperty.Create(nameof(Time), typeof(string), typeof(TimelineItem), string.Empty,
+ propertyChanged: (b, o, n) => ((TimelineItem)b).Update());
+
+ public static readonly BindableProperty TextProperty =
+ BindableProperty.Create(nameof(Text), typeof(string), typeof(TimelineItem), string.Empty,
+ propertyChanged: (b, o, n) => ((TimelineItem)b).Update());
+
+ public static readonly BindableProperty IsActiveProperty =
+ BindableProperty.Create(nameof(IsActive), typeof(bool), typeof(TimelineItem), false,
+ propertyChanged: (b, o, n) => ((TimelineItem)b).Update());
+
+ public static readonly BindableProperty IconProperty =
+ BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(TimelineItem), IconName.None,
+ propertyChanged: (b, o, n) => ((TimelineItem)b).Update());
+
+ public static readonly BindableProperty BodyProperty =
+ BindableProperty.Create(nameof(Body), typeof(View), typeof(TimelineItem), null,
+ propertyChanged: (b, o, n) => ((TimelineItem)b).Update());
+
+ public string Title
+ {
+ get => (string)GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public string Time
+ {
+ get => (string)GetValue(TimeProperty);
+ set => SetValue(TimeProperty, value);
+ }
+
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public bool IsActive
+ {
+ get => (bool)GetValue(IsActiveProperty);
+ set => SetValue(IsActiveProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => (IconName)GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public View? Body
+ {
+ get => (View?)GetValue(BodyProperty);
+ set => SetValue(BodyProperty, value);
+ }
+
+ private const double MarkerSize = 28;
+
+ private readonly BoxView _line;
+ private readonly BoxView _ring;
+ private readonly Border _dot;
+ private readonly Border _badge;
+ private readonly Icon _icon;
+ private readonly Label _title;
+ private readonly Label _time;
+ private readonly Label _text;
+ private readonly ContentView _body;
+ private readonly VerticalStackLayout _details;
+
+ public TimelineItem()
+ {
+ _line = new BoxView { WidthRequest = 1, BackgroundColor = Colors.Transparent, HorizontalOptions = LayoutOptions.Center };
+ _line.Token(BoxView.ColorProperty, ShellToken.Border);
+
+ // ring-4 ring-primary/20 around the active dot.
+ _ring = new BoxView { WidthRequest = 18, HeightRequest = 18, CornerRadius = 9, BackgroundColor = Colors.Transparent, Opacity = 0.2, HorizontalOptions = LayoutOptions.Center, VerticalOptions = LayoutOptions.Center };
+ _ring.Token(BoxView.ColorProperty, ShellToken.Primary);
+ _dot = new Border
+ {
+ WidthRequest = 10,
+ HeightRequest = 10,
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = 5 },
+ HorizontalOptions = LayoutOptions.Center,
+ VerticalOptions = LayoutOptions.Center
+ };
+ _icon = new Icon { Size = 14 };
+ _badge = new Border
+ {
+ Content = _icon,
+ WidthRequest = MarkerSize,
+ HeightRequest = MarkerSize,
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = MarkerSize / 2 }
+ };
+ _badge.Token(VisualElement.BackgroundColorProperty, ShellToken.Background);
+ var marker = new Grid
+ {
+ WidthRequest = MarkerSize,
+ HeightRequest = MarkerSize,
+ VerticalOptions = LayoutOptions.Start,
+ Children = { _ring, _dot, _badge }
+ };
+
+ _title = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold, VerticalTextAlignment = TextAlignment.Center };
+ _title.Token(Label.TextColorProperty, ShellToken.Foreground);
+ _time = new Label { FontSize = 12, VerticalTextAlignment = TextAlignment.Center };
+ _time.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+ _text = new Label { FontSize = 14 };
+ _text.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+ _body = new ContentView();
+ _details = new VerticalStackLayout
+ {
+ Spacing = 2,
+ Children =
+ {
+ new HorizontalStackLayout { Spacing = 8, MinimumHeightRequest = MarkerSize, Children = { _title, _time } },
+ _text,
+ _body
+ }
+ };
+
+ var grid = new Grid
+ {
+ ColumnSpacing = 12,
+ ColumnDefinitions = { new ColumnDefinition(MarkerSize), new ColumnDefinition(GridLength.Star) }
+ };
+ grid.Add(_line, 0, 0);
+ grid.Add(marker, 0, 0);
+ grid.Add(_details, 1, 0);
+ Content = grid;
+ Place(isFirst: true, isLast: true);
+ Update();
+ }
+
+ internal void Place(bool isFirst, bool isLast)
+ {
+ // The line runs through the marker's center: from it down (first), up to it (last), or
+ // the full height (in between). A lone item has no line.
+ _line.IsVisible = !(isFirst && isLast);
+ if (isLast)
+ {
+ _line.VerticalOptions = LayoutOptions.Start;
+ _line.HeightRequest = MarkerSize / 2;
+ _line.Margin = new Thickness(0);
+ }
+ else
+ {
+ _line.VerticalOptions = LayoutOptions.Fill;
+ _line.HeightRequest = -1;
+ _line.Margin = new Thickness(0, isFirst ? MarkerSize / 2 : 0, 0, 0);
+ }
+ _details.Padding = new Thickness(0, 0, 0, isLast ? 0 : 24);
+ }
+
+ private void Update()
+ {
+ var hasIcon = Icon != IconName.None;
+ _badge.IsVisible = hasIcon;
+ _dot.IsVisible = !hasIcon;
+ _ring.IsVisible = !hasIcon && IsActive;
+ _icon.Name = Icon;
+ _icon.Token = IsActive ? ShellToken.Primary : ShellToken.MutedForeground;
+ _badge.Token(Border.StrokeProperty, IsActive ? ShellToken.Primary : ShellToken.Border);
+ _dot.Token(VisualElement.BackgroundColorProperty, IsActive ? ShellToken.Primary : ShellToken.MutedForeground);
+
+ _title.Text = Title ?? string.Empty;
+ _time.Text = Time ?? string.Empty;
+ _time.IsVisible = !string.IsNullOrEmpty(Time);
+ _text.Text = Text ?? string.Empty;
+ _text.IsVisible = !string.IsNullOrEmpty(Text);
+ _body.Content = Body;
+ _body.IsVisible = Body != null;
+ }
+}
+"
+ };
+}
diff --git a/src/ShellUI.Native.Templates/Templates/ToastTemplate.cs b/src/ShellUI.Native.Templates/Templates/ToastTemplate.cs
index 9eac924..3fef025 100644
--- a/src/ShellUI.Native.Templates/Templates/ToastTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/ToastTemplate.cs
@@ -131,6 +131,8 @@ public Toaster()
// Places the toast stack in the page layer (of this Toaster's page, or the page on screen).
internal bool Attach()
{
+ // The stack keeps itself clear of the system bars with its margin (see Fit).
+ ShellPortal.EdgeToEdge(_stack);
if (!ShellPortal.Attach(this, _stack)) return false;
if (_stack.Parent is Grid layer)
{
diff --git a/src/ShellUI.Native.Templates/Templates/ToggleGroupTemplate.cs b/src/ShellUI.Native.Templates/Templates/ToggleGroupTemplate.cs
new file mode 100644
index 0000000..f7cfa52
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/ToggleGroupTemplate.cs
@@ -0,0 +1,266 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class ToggleGroupTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "toggle-group",
+ DisplayName = "Toggle Group",
+ Description = "Row of toggles with single or multiple selection",
+ Category = ComponentCategory.Form,
+ FilePath = "ToggleGroup.cs",
+ Dependencies = new List { "shell", "icon", "element-extensions" },
+ Tags = new List { "toggle", "group", "segmented", "selection" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"using System.Collections.Specialized;
+using Microsoft.Maui.Controls.Shapes;
+
+namespace YourProjectNamespace.Components.UI;
+
+// A row of toggles that work as one control: pick one (default) or several (Multiple).
+//
+//
+//
+//
+// ...
+[ContentProperty(nameof(Items))]
+public partial class ToggleGroup : ContentView
+{
+ public static readonly BindableProperty MultipleProperty =
+ BindableProperty.Create(nameof(Multiple), typeof(bool), typeof(ToggleGroup), false,
+ propertyChanged: (b, o, n) => ((ToggleGroup)b).Refresh());
+
+ // The pressed item's Value in single mode; null when nothing is pressed.
+ public static readonly BindableProperty ValueProperty =
+ BindableProperty.Create(nameof(Value), typeof(string), typeof(ToggleGroup), null, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((ToggleGroup)b).OnValueChanged());
+
+ // The pressed items' Values in Multiple mode.
+ public static readonly BindableProperty ValuesProperty =
+ BindableProperty.Create(nameof(Values), typeof(IList), typeof(ToggleGroup), null, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((ToggleGroup)b).OnValuesReplaced(o, n));
+
+ public static readonly BindableProperty VariantProperty =
+ BindableProperty.Create(nameof(Variant), typeof(ToggleGroupVariant), typeof(ToggleGroup), ToggleGroupVariant.Default,
+ propertyChanged: (b, o, n) => ((ToggleGroup)b).Refresh());
+
+ public bool Multiple
+ {
+ get => (bool)GetValue(MultipleProperty);
+ set => SetValue(MultipleProperty, value);
+ }
+
+ public string? Value
+ {
+ get => (string?)GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public IList? Values
+ {
+ get => (IList?)GetValue(ValuesProperty);
+ set => SetValue(ValuesProperty, value);
+ }
+
+ public ToggleGroupVariant Variant
+ {
+ get => (ToggleGroupVariant)GetValue(VariantProperty);
+ set => SetValue(VariantProperty, value);
+ }
+
+ public IList Items => _row.Children;
+
+ public event EventHandler? ValueChanged;
+ public event EventHandler>? ValuesChanged;
+
+ private readonly HorizontalStackLayout _row;
+
+ public ToggleGroup()
+ {
+ _row = new HorizontalStackLayout { Spacing = 4 };
+ _row.ChildAdded += (_, _) => Refresh();
+ Content = _row;
+ HorizontalOptions = LayoutOptions.Start;
+ Loaded += (_, _) => Refresh();
+ }
+
+ public bool IsPressed(string value) =>
+ Multiple ? Values?.Contains(value) == true : Value == value;
+
+ internal void Toggle(string value)
+ {
+ if (!IsEnabled) return;
+ if (Multiple)
+ {
+ // A new list each time, so a two-way binding sees the change.
+ var next = new List(Values ?? Array.Empty());
+ if (!next.Remove(value)) next.Add(value);
+ Values = next;
+ }
+ else
+ {
+ Value = Value == value ? null : value;
+ }
+ }
+
+ private void OnValueChanged()
+ {
+ Refresh();
+ ValueChanged?.Invoke(this, Value);
+ }
+
+ private void OnValuesReplaced(object? oldValue, object? newValue)
+ {
+ if (oldValue is INotifyCollectionChanged before) before.CollectionChanged -= OnValuesMutated;
+ if (newValue is INotifyCollectionChanged after) after.CollectionChanged += OnValuesMutated;
+ OnValuesMutated(this, null);
+ }
+
+ private void OnValuesMutated(object? sender, NotifyCollectionChangedEventArgs? e)
+ {
+ Refresh();
+ ValuesChanged?.Invoke(this, (Values ?? Array.Empty()).ToList());
+ }
+
+ // Items don't track the group's state: the group pushes it to them.
+ private void Refresh()
+ {
+ foreach (var item in _row.Children.OfType())
+ item.Apply(IsPressed(item.Value), Variant == ToggleGroupVariant.Outline);
+ }
+}
+
+// One toggle in a ToggleGroup — h-9 min-w-9 rounded-md px-2.5, bg-accent while pressed.
+public partial class ToggleGroupItem : ContentView
+{
+ public static readonly BindableProperty ValueProperty =
+ BindableProperty.Create(nameof(Value), typeof(string), typeof(ToggleGroupItem), string.Empty);
+
+ public static readonly BindableProperty TextProperty =
+ BindableProperty.Create(nameof(Text), typeof(string), typeof(ToggleGroupItem), string.Empty,
+ propertyChanged: (b, o, n) => ((ToggleGroupItem)b).Update());
+
+ public static readonly BindableProperty IconProperty =
+ BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(ToggleGroupItem), IconName.None,
+ propertyChanged: (b, o, n) => ((ToggleGroupItem)b).Update());
+
+ public string Value
+ {
+ get => (string)GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => (IconName)GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ private readonly Border _border;
+ private readonly Icon _icon;
+ private readonly Label _label;
+ private bool _pressed;
+ private bool _outline;
+ private bool _hovered;
+
+ public ToggleGroupItem()
+ {
+ _icon = new Icon { Size = 16, IsVisible = false };
+ _label = new Label
+ {
+ FontSize = 14,
+ FontAttributes = FontAttributes.Bold,
+ VerticalOptions = LayoutOptions.Center,
+ VerticalTextAlignment = TextAlignment.Center,
+ LineBreakMode = LineBreakMode.NoWrap
+ };
+ _border = new Border
+ {
+ Content = new HorizontalStackLayout
+ {
+ Spacing = 8,
+ HorizontalOptions = LayoutOptions.Center,
+ VerticalOptions = LayoutOptions.Center,
+ Children = { _icon, _label }
+ },
+ HeightRequest = 36,
+ MinimumWidthRequest = 36,
+ Padding = new Thickness(10, 0),
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }
+ };
+
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => { _hovered = true; Update(); };
+ pointer.PointerExited += (_, _) => { _hovered = false; Update(); };
+ _border.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { ShellFocus.FocusPressed(this); Press(); };
+ _border.GestureRecognizers.Add(tap);
+ ShellFocus.MakeFocusable(this, Press);
+
+ Content = _border;
+ Update();
+ }
+
+ private void Press()
+ {
+ if (IsEnabled) this.FindParentOfType()?.Toggle(Value);
+ }
+
+ internal void Apply(bool pressed, bool outline)
+ {
+ _pressed = pressed;
+ _outline = outline;
+ Update();
+ }
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == IsEnabledProperty.PropertyName)
+ Opacity = IsEnabled ? 1.0 : 0.5;
+ }
+
+ private void Update()
+ {
+ _label.Text = Text ?? string.Empty;
+ _label.IsVisible = !string.IsNullOrEmpty(Text);
+ _icon.Name = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+
+ _border.StrokeThickness = _outline ? 1 : 0;
+ if (_outline) _border.Token(Border.StrokeProperty, ShellToken.Input);
+
+ var foreground = _pressed ? ShellToken.AccentForeground
+ : _hovered ? ShellToken.MutedForeground
+ : ShellToken.Foreground;
+ _label.Token(Label.TextColorProperty, foreground);
+ _icon.Token = foreground;
+
+ if (_pressed)
+ _border.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent);
+ else if (_hovered && IsEnabled)
+ _border.Token(VisualElement.BackgroundColorProperty, ShellToken.Muted);
+ else
+ {
+ _border.ClearValue(VisualElement.BackgroundColorProperty);
+ _border.BackgroundColor = Colors.Transparent;
+ }
+ }
+}
+
+public enum ToggleGroupVariant { Default, Outline }
+"
+ };
+}
diff --git a/src/ShellUI.Native.Templates/Templates/TreeViewTemplate.cs b/src/ShellUI.Native.Templates/Templates/TreeViewTemplate.cs
new file mode 100644
index 0000000..08cf733
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/TreeViewTemplate.cs
@@ -0,0 +1,240 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class TreeViewTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "tree-view",
+ DisplayName = "Tree View",
+ Description = "Expandable tree of rows with selection",
+ Category = ComponentCategory.Navigation,
+ FilePath = "TreeView.cs",
+ Dependencies = new List { "shell", "icon", "element-extensions" },
+ Tags = new List { "tree", "hierarchy", "files", "navigation" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"using Microsoft.Maui.Controls.Shapes;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Tree of expandable rows — rounded-md px-2 py-1.5 text-sm rows, hover:bg-muted, bg-accent when
+// selected; children are indented behind a hairline. Tapping a row selects it and, when it has
+// children, expands or collapses it.
+//
+//
+//
+//
+//
+[ContentProperty(nameof(Items))]
+public partial class TreeView : ContentView
+{
+ // The selected item's Value (its Text when no Value is set).
+ public static readonly BindableProperty SelectedValueProperty =
+ BindableProperty.Create(nameof(SelectedValue), typeof(string), typeof(TreeView), null, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((TreeView)b).OnSelectedValueChanged());
+
+ public string? SelectedValue
+ {
+ get => (string?)GetValue(SelectedValueProperty);
+ set => SetValue(SelectedValueProperty, value);
+ }
+
+ public IList Items => _stack.Children;
+
+ public event EventHandler? SelectedValueChanged;
+
+ private readonly VerticalStackLayout _stack;
+
+ public TreeView()
+ {
+ _stack = new VerticalStackLayout { Spacing = 2 };
+ Content = _stack;
+ Loaded += (_, _) => Refresh();
+ }
+
+ private void OnSelectedValueChanged()
+ {
+ Refresh();
+ SelectedValueChanged?.Invoke(this, SelectedValue);
+ }
+
+ // Items don't track the selection: the tree pushes it to every item under it.
+ private void Refresh()
+ {
+ foreach (var item in this.FindDescendantsOfType())
+ item.ApplySelection(SelectedValue);
+ }
+}
+
+// One row of a TreeView. Nested TreeViewItems become its children.
+[ContentProperty(nameof(Items))]
+public partial class TreeViewItem : ContentView
+{
+ public static readonly BindableProperty TextProperty =
+ BindableProperty.Create(nameof(Text), typeof(string), typeof(TreeViewItem), string.Empty,
+ propertyChanged: (b, o, n) => ((TreeViewItem)b).Update());
+
+ // Identifies the item for selection. Defaults to Text.
+ public static readonly BindableProperty ValueProperty =
+ BindableProperty.Create(nameof(Value), typeof(string), typeof(TreeViewItem), null);
+
+ public static readonly BindableProperty IconProperty =
+ BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(TreeViewItem), IconName.None,
+ propertyChanged: (b, o, n) => ((TreeViewItem)b).Update());
+
+ public static readonly BindableProperty IsExpandedProperty =
+ BindableProperty.Create(nameof(IsExpanded), typeof(bool), typeof(TreeViewItem), false, BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((TreeViewItem)b).OnExpandedChanged());
+
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public string? Value
+ {
+ get => (string?)GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => (IconName)GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public bool IsExpanded
+ {
+ get => (bool)GetValue(IsExpandedProperty);
+ set => SetValue(IsExpandedProperty, value);
+ }
+
+ public IList Items => _children.Children;
+
+ public string EffectiveValue => Value ?? Text ?? string.Empty;
+
+ private readonly Border _row;
+ private readonly Icon _chevron;
+ private readonly Icon _icon;
+ private readonly Label _label;
+ private readonly Grid _childrenHost;
+ private readonly VerticalStackLayout _children;
+ private bool _selected;
+ private bool _hovered;
+
+ public TreeViewItem()
+ {
+ _chevron = new Icon { Name = IconName.ChevronRight, Size = 16, Token = ShellToken.MutedForeground };
+ _icon = new Icon { Size = 16, Token = ShellToken.MutedForeground, IsVisible = false };
+ _label = new Label
+ {
+ FontSize = 14,
+ VerticalTextAlignment = TextAlignment.Center,
+ VerticalOptions = LayoutOptions.Center,
+ LineBreakMode = LineBreakMode.TailTruncation
+ };
+
+ // The chevron keeps its 16px slot on leaves, so labels line up within a level.
+ var slot = new Grid { WidthRequest = 16, HeightRequest = 16, VerticalOptions = LayoutOptions.Center, Children = { _chevron } };
+ var content = new Grid
+ {
+ ColumnSpacing = 6,
+ ColumnDefinitions =
+ {
+ new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star)
+ }
+ };
+ content.Add(slot, 0, 0);
+ content.Add(_icon, 1, 0);
+ content.Add(_label, 2, 0);
+
+ _row = new Border
+ {
+ Content = content,
+ HeightRequest = 32,
+ Padding = new Thickness(8, 0),
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ BackgroundColor = Colors.Transparent
+ };
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => { _hovered = true; Paint(); };
+ pointer.PointerExited += (_, _) => { _hovered = false; Paint(); };
+ _row.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { ShellFocus.FocusPressed(this); Press(); };
+ _row.GestureRecognizers.Add(tap);
+ ShellFocus.MakeFocusable(this, Press);
+
+ // ml-4 border-l pl-2: a hairline under the parent's chevron, children to its right.
+ var guide = new BoxView { WidthRequest = 1, BackgroundColor = Colors.Transparent, HorizontalOptions = LayoutOptions.Start, Margin = new Thickness(16, 0, 0, 0) };
+ guide.Token(BoxView.ColorProperty, ShellToken.Border);
+ _children = new VerticalStackLayout { Spacing = 2, Margin = new Thickness(25, 0, 0, 0) };
+ _children.ChildAdded += (_, _) => Update();
+ _children.ChildRemoved += (_, _) => Update();
+ _childrenHost = new Grid { Margin = new Thickness(0, 2, 0, 0), IsVisible = false, Children = { guide, _children } };
+
+ Content = new VerticalStackLayout { Spacing = 0, Children = { _row, _childrenHost } };
+ Update();
+ }
+
+ private bool HasChildren => _children.Children.Count > 0;
+
+ private void Press()
+ {
+ if (!IsEnabled) return;
+ if (HasChildren) IsExpanded = !IsExpanded;
+ if (this.FindParentOfType() is { } tree) tree.SelectedValue = EffectiveValue;
+ }
+
+ internal void ApplySelection(string? selected)
+ {
+ _selected = selected != null && selected == EffectiveValue;
+ Paint();
+ }
+
+ private void OnExpandedChanged() => Update();
+
+ private void Update()
+ {
+ _label.Text = Text ?? string.Empty;
+ _icon.Name = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ _chevron.IsVisible = HasChildren;
+ // A second icon rather than a rotation: a view rotated before its first layout pivots
+ // around its corner on Android and ends up outside its slot.
+ _chevron.Name = IsExpanded ? IconName.ChevronDown : IconName.ChevronRight;
+ _childrenHost.IsVisible = HasChildren && IsExpanded;
+ Paint();
+ }
+
+ private void Paint()
+ {
+ _label.FontAttributes = _selected ? FontAttributes.Bold : FontAttributes.None;
+ _label.Token(Label.TextColorProperty, _selected ? ShellToken.AccentForeground : ShellToken.Foreground);
+ if (_selected)
+ _row.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent);
+ else if (_hovered && IsEnabled)
+ _row.Token(VisualElement.BackgroundColorProperty, ShellToken.Muted);
+ else
+ {
+ _row.ClearValue(VisualElement.BackgroundColorProperty);
+ _row.BackgroundColor = Colors.Transparent;
+ }
+ }
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == IsEnabledProperty.PropertyName)
+ Opacity = IsEnabled ? 1.0 : 0.5;
+ }
+}
+"
+ };
+}
diff --git a/src/ShellUI.Native.Templates/Templates/WrapLayoutTemplate.cs b/src/ShellUI.Native.Templates/Templates/WrapLayoutTemplate.cs
new file mode 100644
index 0000000..22599b5
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/WrapLayoutTemplate.cs
@@ -0,0 +1,167 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class WrapLayoutTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "wrap-layout",
+ DisplayName = "Wrap Layout",
+ Description = "Children flow left to right and wrap onto new rows",
+ Category = ComponentCategory.Layout,
+ FilePath = "WrapLayout.cs",
+ Dependencies = new List(),
+ Tags = new List { "wrap", "flow", "flex", "layout" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"using Microsoft.Maui.Layouts;
+
+namespace YourProjectNamespace.Components.UI;
+
+// flex flex-wrap gap-2 — children flow left to right and wrap onto new rows, centered vertically
+// within their row. Use it for button rows, chips and tag lists.
+//
+// ...
+//
+// Each child is measured once per layout pass.
+public partial class WrapLayout : Layout
+{
+ // Gap between children in a row.
+ public static readonly BindableProperty SpacingProperty =
+ BindableProperty.Create(nameof(Spacing), typeof(double), typeof(WrapLayout), 8d,
+ propertyChanged: (b, o, n) => ((WrapLayout)b).InvalidateMeasure());
+
+ // Gap between rows.
+ public static readonly BindableProperty LineSpacingProperty =
+ BindableProperty.Create(nameof(LineSpacing), typeof(double), typeof(WrapLayout), 8d,
+ propertyChanged: (b, o, n) => ((WrapLayout)b).InvalidateMeasure());
+
+ // The last child takes what is left of its row (or a row of its own when less than
+ // MinimumLastChildWidth remains) — e.g. the text field after a run of chips.
+ public static readonly BindableProperty LastChildFillProperty =
+ BindableProperty.Create(nameof(LastChildFill), typeof(bool), typeof(WrapLayout), false,
+ propertyChanged: (b, o, n) => ((WrapLayout)b).InvalidateMeasure());
+
+ public static readonly BindableProperty MinimumLastChildWidthProperty =
+ BindableProperty.Create(nameof(MinimumLastChildWidth), typeof(double), typeof(WrapLayout), 96d,
+ propertyChanged: (b, o, n) => ((WrapLayout)b).InvalidateMeasure());
+
+ public double Spacing
+ {
+ get => (double)GetValue(SpacingProperty);
+ set => SetValue(SpacingProperty, value);
+ }
+
+ public double LineSpacing
+ {
+ get => (double)GetValue(LineSpacingProperty);
+ set => SetValue(LineSpacingProperty, value);
+ }
+
+ public bool LastChildFill
+ {
+ get => (bool)GetValue(LastChildFillProperty);
+ set => SetValue(LastChildFillProperty, value);
+ }
+
+ public double MinimumLastChildWidth
+ {
+ get => (double)GetValue(MinimumLastChildWidthProperty);
+ set => SetValue(MinimumLastChildWidthProperty, value);
+ }
+
+ protected override ILayoutManager CreateLayoutManager() => new Manager(this);
+
+ private sealed class Manager(WrapLayout layout) : ILayoutManager
+ {
+ public Size Measure(double widthConstraint, double heightConstraint)
+ {
+ var padding = layout.Padding;
+ var bounded = !double.IsInfinity(widthConstraint);
+ var available = bounded ? Math.Max(0, widthConstraint - padding.HorizontalThickness) : double.PositiveInfinity;
+ var content = Flow(available, measure: true, origin: null);
+ return new Size(content.Width + padding.HorizontalThickness, content.Height + padding.VerticalThickness);
+ }
+
+ public Size ArrangeChildren(Rect bounds)
+ {
+ var padding = layout.Padding;
+ var available = Math.Max(0, bounds.Width - padding.HorizontalThickness);
+ // Sizes come from the measure pass: measuring again here would restart layout.
+ Flow(available, measure: false, origin: new Point(bounds.X + padding.Left, bounds.Y + padding.Top));
+ return bounds.Size;
+ }
+
+ private Size Flow(double available, bool measure, Point? origin)
+ {
+ var bounded = !double.IsInfinity(available);
+ var row = new List<(IView View, double X, double Width, double Height)>();
+ double x = 0, y = 0, rowHeight = 0, widest = 0;
+
+ var last = -1;
+ for (var i = layout.Count - 1; i >= 0 && last < 0; i--)
+ if (layout[i].Visibility != Visibility.Collapsed) last = i;
+
+ void EndRow()
+ {
+ if (origin is { } at)
+ {
+ foreach (var (view, left, width, height) in row)
+ view.Arrange(new Rect(at.X + left, at.Y + y + (rowHeight - height) / 2, width, height));
+ }
+ row.Clear();
+ y += rowHeight + layout.LineSpacing;
+ x = 0;
+ rowHeight = 0;
+ }
+
+ for (var i = 0; i <= last; i++)
+ {
+ var child = layout[i];
+ if (child.Visibility == Visibility.Collapsed) continue;
+
+ var start = row.Count > 0 ? x + layout.Spacing : 0;
+ double width, height;
+ if (layout.LastChildFill && i == last)
+ {
+ var rest = bounded ? available - start : layout.MinimumLastChildWidth;
+ if (bounded && rest < layout.MinimumLastChildWidth && row.Count > 0)
+ {
+ EndRow();
+ start = 0;
+ rest = available;
+ }
+ width = Math.Max(0, rest);
+ height = measure ? child.Measure(width, double.PositiveInfinity).Height : child.DesiredSize.Height;
+ }
+ else
+ {
+ var size = measure ? child.Measure(available, double.PositiveInfinity) : child.DesiredSize;
+ width = bounded ? Math.Min(size.Width, available) : size.Width;
+ height = size.Height;
+ // Half a pixel of slack so rounding can't wrap differently between passes.
+ if (bounded && row.Count > 0 && start + width > available + 0.5)
+ {
+ EndRow();
+ start = 0;
+ }
+ }
+
+ row.Add((child, start, width, height));
+ x = start + width;
+ rowHeight = Math.Max(rowHeight, height);
+ widest = Math.Max(widest, x);
+ }
+
+ var total = y + rowHeight;
+ if (row.Count > 0) EndRow();
+ return new Size(widest, last < 0 ? 0 : total);
+ }
+ }
+}
+"
+ };
+}