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 @@ - - - - - - - - - - - - + + + + + + + + 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); + } + } +} +" + }; +}