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