From fecf6c1bc0eb27adf09c67f00d5d1eca00f3f3f2 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 29 Aug 2026 21:57:16 +0200 Subject: [PATCH 01/20] feat(p3): add collapsible family (collapsible + trigger + content) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First P3 primitive per Phase 1c build order. Establishes the IsOpen/animation pattern that accordion-item and tabs will build on: - Collapsible : VerticalStackLayout — holds Open state, exposes SetOpen / Toggle, raises OpenChanged for content sync - CollapsibleTrigger : ContentView — tap toggles the parent. MinimumHeightRequest = 40 per the Form Sizing Contract (row-level control). - CollapsibleContent : ContentView — subscribes to parent OpenChanged, fades over 150ms + toggles IsVisible. Chose fade + visibility over height animation because MAUI's height animation needs measured child height and is unreliable across Windows/Android/iOS; fade + IsVisible is not. No new tokens introduced. Tests auto-picked up the 3 new components: 199 → 211. Co-Authored-By: Claude Opus 4.7 --- .../ComponentRegistry.cs | 7 +- .../Templates/CollapsibleContentTemplate.cs | 77 +++++++++++++++++++ .../Templates/CollapsibleTemplate.cs | 62 +++++++++++++++ .../Templates/CollapsibleTriggerTemplate.cs | 44 +++++++++++ 4 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 src/ShellUI.Native.Templates/Templates/CollapsibleContentTemplate.cs create mode 100644 src/ShellUI.Native.Templates/Templates/CollapsibleTemplate.cs create mode 100644 src/ShellUI.Native.Templates/Templates/CollapsibleTriggerTemplate.cs diff --git a/src/ShellUI.Native.Templates/ComponentRegistry.cs b/src/ShellUI.Native.Templates/ComponentRegistry.cs index 1aec936..c27fbac 100644 --- a/src/ShellUI.Native.Templates/ComponentRegistry.cs +++ b/src/ShellUI.Native.Templates/ComponentRegistry.cs @@ -69,7 +69,12 @@ public static class ComponentRegistry { "radio-group", (RadioGroupTemplate.Metadata, RadioGroupTemplate.Contents) }, { "radio-group-item", (RadioGroupItemTemplate.Metadata, RadioGroupItemTemplate.Contents) }, { "date-picker", (DatePickerTemplate.Metadata, DatePickerTemplate.Contents) }, - { "time-picker", (TimePickerTemplate.Metadata, TimePickerTemplate.Contents) } + { "time-picker", (TimePickerTemplate.Metadata, TimePickerTemplate.Contents) }, + + // Navigation / Layout - P3 + { "collapsible", (CollapsibleTemplate.Metadata, CollapsibleTemplate.Contents) }, + { "collapsible-trigger", (CollapsibleTriggerTemplate.Metadata, CollapsibleTriggerTemplate.Contents) }, + { "collapsible-content", (CollapsibleContentTemplate.Metadata, CollapsibleContentTemplate.Contents) } }; // Metadata-only view for callers that don't need the per-platform payload. diff --git a/src/ShellUI.Native.Templates/Templates/CollapsibleContentTemplate.cs b/src/ShellUI.Native.Templates/Templates/CollapsibleContentTemplate.cs new file mode 100644 index 0000000..da1092c --- /dev/null +++ b/src/ShellUI.Native.Templates/Templates/CollapsibleContentTemplate.cs @@ -0,0 +1,77 @@ +using ShellUI.Native.Core.Models; + +namespace ShellUI.Native.Templates.Templates; + +// CollapsibleContent - inline panel that appears/disappears with the parent Collapsible's +// Open state. Fades over 150ms rather than animating height: cross-platform height animation +// in MAUI is unreliable (needs measured child height), fade + visibility toggle is not. +public static class CollapsibleContentTemplate +{ + public static ComponentMetadata Metadata => new() + { + Name = "collapsible-content", + DisplayName = "Collapsible Content", + Description = "Body of a Collapsible - shown when the parent Collapsible is Open. Place inside Collapsible", + Category = ComponentCategory.Layout, + FilePath = "CollapsibleContent.cs", + Dependencies = new List { "element-extensions" }, + Tags = new List { "layout", "collapsible", "content" } + }; + + public static IReadOnlyDictionary Contents { get; } = new Dictionary + { + [NativePlatform.MAUI] = @"namespace YourProjectNamespace.Components.UI; + +[ContentProperty(nameof(Content))] +public partial class CollapsibleContent : ContentView +{ + private Collapsible? _parent; + + public CollapsibleContent() + { + IsVisible = false; + Opacity = 0; + } + + protected override void OnParentSet() + { + base.OnParentSet(); + + // Detach from any previous parent to avoid duplicate handlers if the view is reparented. + if (_parent != null) + _parent.OpenChanged -= OnParentOpenChanged; + + _parent = this.FindParentOfType(); + if (_parent != null) + { + _parent.OpenChanged += OnParentOpenChanged; + ApplyState(_parent.Open, animate: false); + } + } + + private void OnParentOpenChanged(object? sender, bool open) => _ = AnimateAsync(open); + + private async Task AnimateAsync(bool open) + { + if (open) + { + IsVisible = true; + await this.FadeTo(1, 150, Easing.CubicOut); + } + else + { + await this.FadeTo(0, 150, Easing.CubicIn); + IsVisible = false; + } + } + + private void ApplyState(bool open, bool animate) + { + if (animate) { _ = AnimateAsync(open); return; } + IsVisible = open; + Opacity = open ? 1 : 0; + } +} +" + }; +} diff --git a/src/ShellUI.Native.Templates/Templates/CollapsibleTemplate.cs b/src/ShellUI.Native.Templates/Templates/CollapsibleTemplate.cs new file mode 100644 index 0000000..78bbb31 --- /dev/null +++ b/src/ShellUI.Native.Templates/Templates/CollapsibleTemplate.cs @@ -0,0 +1,62 @@ +using ShellUI.Native.Core.Models; + +namespace ShellUI.Native.Templates.Templates; + +// Collapsible - single expand/collapse primitive. Compositional parent for +// CollapsibleTrigger + CollapsibleContent. Also the reusable IsOpen/animation +// primitive that AccordionItem builds on. +public static class CollapsibleTemplate +{ + public static ComponentMetadata Metadata => new() + { + Name = "collapsible", + DisplayName = "Collapsible", + Description = "Single expand/collapse container - use with CollapsibleTrigger and CollapsibleContent", + Category = ComponentCategory.Layout, + FilePath = "Collapsible.cs", + Dependencies = new List { "element-extensions", "collapsible-trigger", "collapsible-content" }, + Tags = new List { "layout", "collapsible", "disclosure", "expand" } + }; + + public static IReadOnlyDictionary Contents { get; } = new Dictionary + { + [NativePlatform.MAUI] = @"namespace YourProjectNamespace.Components.UI; + +// Compositional expand/collapse. Usage: +// +// +// +// +public partial class Collapsible : VerticalStackLayout +{ + public static readonly BindableProperty OpenProperty = + BindableProperty.Create(nameof(Open), typeof(bool), typeof(Collapsible), false, + propertyChanged: (b, o, n) => (b as Collapsible)?.OnOpenChanged()); + + public bool Open + { + get => (bool)GetValue(OpenProperty); + set => SetValue(OpenProperty, value); + } + + // Raised whenever Open flips. CollapsibleContent subscribes to drive its visibility; + // consumers may also subscribe for side effects (analytics, lazy loading, etc.). + public event EventHandler? OpenChanged; + + public Collapsible() + { + Spacing = 0; + } + + public void SetOpen(bool value) + { + if (Open != value) Open = value; + } + + public void Toggle() => SetOpen(!Open); + + private void OnOpenChanged() => OpenChanged?.Invoke(this, Open); +} +" + }; +} diff --git a/src/ShellUI.Native.Templates/Templates/CollapsibleTriggerTemplate.cs b/src/ShellUI.Native.Templates/Templates/CollapsibleTriggerTemplate.cs new file mode 100644 index 0000000..e5cd627 --- /dev/null +++ b/src/ShellUI.Native.Templates/Templates/CollapsibleTriggerTemplate.cs @@ -0,0 +1,44 @@ +using ShellUI.Native.Core.Models; + +namespace ShellUI.Native.Templates.Templates; + +// CollapsibleTrigger - tap toggles the parent Collapsible's Open state. +// Row-level control: honors the 40px form baseline via MinimumHeightRequest. +public static class CollapsibleTriggerTemplate +{ + public static ComponentMetadata Metadata => new() + { + Name = "collapsible-trigger", + DisplayName = "Collapsible Trigger", + Description = "Tap-to-toggle handle for a Collapsible - place inside Collapsible", + Category = ComponentCategory.Layout, + FilePath = "CollapsibleTrigger.cs", + Dependencies = new List { "element-extensions" }, + Tags = new List { "layout", "collapsible", "trigger" } + }; + + public static IReadOnlyDictionary Contents { get; } = new Dictionary + { + [NativePlatform.MAUI] = @"namespace YourProjectNamespace.Components.UI; + +// Tap to toggle. Wrap your visible handle - a Label, an icon, a whole row. +// MinimumHeightRequest = 40 keeps hit target aligned with Input / Select / Button +// (Form Sizing Contract). +public partial class CollapsibleTrigger : ContentView +{ + public CollapsibleTrigger() + { + MinimumHeightRequest = 40; + var tap = new TapGestureRecognizer(); + tap.Tapped += OnTapped; + GestureRecognizers.Add(tap); + } + + private void OnTapped(object? sender, TappedEventArgs e) + { + this.FindParentOfType()?.Toggle(); + } +} +" + }; +} From 6331375479bf07f960f81f5f00d841aadfff63a1 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 29 Aug 2026 22:02:57 +0200 Subject: [PATCH 02/20] feat(p3): add accordion, tabs, breadcrumb, skeleton, scroll-area families MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the Phase 1c P3 tier (11 new components) after collapsible: - accordion + accordion-item + accordion-trigger + accordion-content Single (radio-style) or Multiple type. Accordion holds the open-value set; items derive their IsOpen from it and raise their own OpenChanged so triggers and content don't need to know about the Accordion above them. Fade+visibility animation matches Collapsible. - tabs + tabs-list + tabs-trigger + tabs-content Tabs.Value tracks the active id. Trigger tap sets it; triggers observe ValueChanged and switch between the Primary (#2563EB, bold, underline) and Muted foreground (#6B7280) states. Content just toggles IsVisible on Value match — no fade to avoid trailing animations on rapid tab switches. MinimumHeightRequest = 40 per Form Sizing Contract (row-level trigger). - breadcrumb + breadcrumb-item Each item owns its own trailing separator; parent tells the last item to hide it via SetSeparatorVisible. Keeps OnChildAdded free of mutating the child list. IsCurrent renders bold with foreground color and suppresses tap. - skeleton Pulses Opacity 1.0 <-> 0.5 on a 1200ms sin loop. Restarts on reparenting so it survives navigation. CornerRadius bindable, defaults 4. - scroll-area Thin ScrollView subclass. Deliberately minimal at v1 per the roadmap note — a named hook for future scrollbar styling / fade-edge, no visual overhead yet. Tests auto-picked up all 11 new components: 211 -> 259. No new tokens introduced; every color reuses Primary (#2563EB), Foreground (#1F2937), Muted foreground (#6B7280), Border/Placeholder (#E5E7EB, #9CA3AF). Co-Authored-By: Claude Opus 4.7 --- .../ComponentRegistry.cs | 14 ++- .../Templates/AccordionContentTemplate.cs | 67 +++++++++++ .../Templates/AccordionItemTemplate.cs | 78 +++++++++++++ .../Templates/AccordionTemplate.cs | 80 +++++++++++++ .../Templates/AccordionTriggerTemplate.cs | 34 ++++++ .../Templates/BreadcrumbItemTemplate.cs | 82 ++++++++++++++ .../Templates/BreadcrumbTemplate.cs | 53 +++++++++ .../Templates/ScrollAreaTemplate.cs | 36 ++++++ .../Templates/SkeletonTemplate.cs | 73 ++++++++++++ .../Templates/TabsContentTemplate.cs | 66 +++++++++++ .../Templates/TabsListTemplate.cs | 33 ++++++ .../Templates/TabsTemplate.cs | 62 ++++++++++ .../Templates/TabsTriggerTemplate.cs | 106 ++++++++++++++++++ 13 files changed, 783 insertions(+), 1 deletion(-) create mode 100644 src/ShellUI.Native.Templates/Templates/AccordionContentTemplate.cs create mode 100644 src/ShellUI.Native.Templates/Templates/AccordionItemTemplate.cs create mode 100644 src/ShellUI.Native.Templates/Templates/AccordionTemplate.cs create mode 100644 src/ShellUI.Native.Templates/Templates/AccordionTriggerTemplate.cs create mode 100644 src/ShellUI.Native.Templates/Templates/BreadcrumbItemTemplate.cs create mode 100644 src/ShellUI.Native.Templates/Templates/BreadcrumbTemplate.cs create mode 100644 src/ShellUI.Native.Templates/Templates/ScrollAreaTemplate.cs create mode 100644 src/ShellUI.Native.Templates/Templates/SkeletonTemplate.cs create mode 100644 src/ShellUI.Native.Templates/Templates/TabsContentTemplate.cs create mode 100644 src/ShellUI.Native.Templates/Templates/TabsListTemplate.cs create mode 100644 src/ShellUI.Native.Templates/Templates/TabsTemplate.cs create mode 100644 src/ShellUI.Native.Templates/Templates/TabsTriggerTemplate.cs diff --git a/src/ShellUI.Native.Templates/ComponentRegistry.cs b/src/ShellUI.Native.Templates/ComponentRegistry.cs index c27fbac..25e26e1 100644 --- a/src/ShellUI.Native.Templates/ComponentRegistry.cs +++ b/src/ShellUI.Native.Templates/ComponentRegistry.cs @@ -74,7 +74,19 @@ public static class ComponentRegistry // Navigation / Layout - P3 { "collapsible", (CollapsibleTemplate.Metadata, CollapsibleTemplate.Contents) }, { "collapsible-trigger", (CollapsibleTriggerTemplate.Metadata, CollapsibleTriggerTemplate.Contents) }, - { "collapsible-content", (CollapsibleContentTemplate.Metadata, CollapsibleContentTemplate.Contents) } + { "collapsible-content", (CollapsibleContentTemplate.Metadata, CollapsibleContentTemplate.Contents) }, + { "accordion", (AccordionTemplate.Metadata, AccordionTemplate.Contents) }, + { "accordion-item", (AccordionItemTemplate.Metadata, AccordionItemTemplate.Contents) }, + { "accordion-trigger", (AccordionTriggerTemplate.Metadata, AccordionTriggerTemplate.Contents) }, + { "accordion-content", (AccordionContentTemplate.Metadata, AccordionContentTemplate.Contents) }, + { "tabs", (TabsTemplate.Metadata, TabsTemplate.Contents) }, + { "tabs-list", (TabsListTemplate.Metadata, TabsListTemplate.Contents) }, + { "tabs-trigger", (TabsTriggerTemplate.Metadata, TabsTriggerTemplate.Contents) }, + { "tabs-content", (TabsContentTemplate.Metadata, TabsContentTemplate.Contents) }, + { "breadcrumb", (BreadcrumbTemplate.Metadata, BreadcrumbTemplate.Contents) }, + { "breadcrumb-item", (BreadcrumbItemTemplate.Metadata, BreadcrumbItemTemplate.Contents) }, + { "skeleton", (SkeletonTemplate.Metadata, SkeletonTemplate.Contents) }, + { "scroll-area", (ScrollAreaTemplate.Metadata, ScrollAreaTemplate.Contents) } }; // Metadata-only view for callers that don't need the per-platform payload. diff --git a/src/ShellUI.Native.Templates/Templates/AccordionContentTemplate.cs b/src/ShellUI.Native.Templates/Templates/AccordionContentTemplate.cs new file mode 100644 index 0000000..d36fb0f --- /dev/null +++ b/src/ShellUI.Native.Templates/Templates/AccordionContentTemplate.cs @@ -0,0 +1,67 @@ +using ShellUI.Native.Core.Models; + +namespace ShellUI.Native.Templates.Templates; + +public static class AccordionContentTemplate +{ + public static ComponentMetadata Metadata => new() + { + Name = "accordion-content", + DisplayName = "Accordion Content", + Description = "Body shown when the enclosing AccordionItem is open", + Category = ComponentCategory.Layout, + FilePath = "AccordionContent.cs", + Dependencies = new List { "element-extensions" }, + Tags = new List { "layout", "accordion", "content" } + }; + + public static IReadOnlyDictionary Contents { get; } = new Dictionary + { + [NativePlatform.MAUI] = @"namespace YourProjectNamespace.Components.UI; + +[ContentProperty(nameof(Content))] +public partial class AccordionContent : ContentView +{ + private AccordionItem? _item; + + public AccordionContent() + { + IsVisible = false; + Opacity = 0; + } + + protected override void OnParentSet() + { + base.OnParentSet(); + + if (_item != null) + _item.OpenChanged -= OnItemOpenChanged; + + _item = this.FindParentOfType(); + if (_item != null) + { + _item.OpenChanged += OnItemOpenChanged; + IsVisible = _item.IsOpen; + Opacity = _item.IsOpen ? 1 : 0; + } + } + + private void OnItemOpenChanged(object? sender, bool open) => _ = AnimateAsync(open); + + private async Task AnimateAsync(bool open) + { + if (open) + { + IsVisible = true; + await this.FadeTo(1, 150, Easing.CubicOut); + } + else + { + await this.FadeTo(0, 150, Easing.CubicIn); + IsVisible = false; + } + } +} +" + }; +} diff --git a/src/ShellUI.Native.Templates/Templates/AccordionItemTemplate.cs b/src/ShellUI.Native.Templates/Templates/AccordionItemTemplate.cs new file mode 100644 index 0000000..6a5b6b9 --- /dev/null +++ b/src/ShellUI.Native.Templates/Templates/AccordionItemTemplate.cs @@ -0,0 +1,78 @@ +using ShellUI.Native.Core.Models; + +namespace ShellUI.Native.Templates.Templates; + +// AccordionItem - one expandable row. Its IsOpen is derived from the parent Accordion. +public static class AccordionItemTemplate +{ + public static ComponentMetadata Metadata => new() + { + Name = "accordion-item", + DisplayName = "Accordion Item", + Description = "Single row of an Accordion - contains AccordionTrigger + AccordionContent", + Category = ComponentCategory.Layout, + FilePath = "AccordionItem.cs", + Dependencies = new List { "element-extensions" }, + Tags = new List { "layout", "accordion", "item" } + }; + + public static IReadOnlyDictionary Contents { get; } = new Dictionary + { + [NativePlatform.MAUI] = @"namespace YourProjectNamespace.Components.UI; + +public partial class AccordionItem : VerticalStackLayout +{ + public static readonly BindableProperty ValueProperty = + BindableProperty.Create(nameof(Value), typeof(string), typeof(AccordionItem), string.Empty); + + public string Value + { + get => (string)GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + // Item-local view of parent's open set. Trigger/Content subscribe here so they don't + // have to know about the Accordion above. + public bool IsOpen { get; private set; } + public event EventHandler? OpenChanged; + + private Accordion? _parent; + + public AccordionItem() + { + Spacing = 0; + } + + protected override void OnParentSet() + { + base.OnParentSet(); + + if (_parent != null) + _parent.ItemToggled -= OnParentToggled; + + _parent = this.FindParentOfType(); + if (_parent != null) + { + _parent.ItemToggled += OnParentToggled; + var open = _parent.IsOpen(Value); + if (open != IsOpen) + { + IsOpen = open; + OpenChanged?.Invoke(this, IsOpen); + } + } + } + + private void OnParentToggled(object? sender, (string Value, bool IsOpen) e) + { + if (e.Value != Value) return; + if (IsOpen == e.IsOpen) return; + IsOpen = e.IsOpen; + OpenChanged?.Invoke(this, IsOpen); + } + + public void Toggle() => _parent?.Toggle(Value); +} +" + }; +} diff --git a/src/ShellUI.Native.Templates/Templates/AccordionTemplate.cs b/src/ShellUI.Native.Templates/Templates/AccordionTemplate.cs new file mode 100644 index 0000000..8613d51 --- /dev/null +++ b/src/ShellUI.Native.Templates/Templates/AccordionTemplate.cs @@ -0,0 +1,80 @@ +using ShellUI.Native.Core.Models; + +namespace ShellUI.Native.Templates.Templates; + +// Accordion - stack of AccordionItems with sibling coordination. +// Type=Single: at most one item open (radio-style). Type=Multiple: any combination. +public static class AccordionTemplate +{ + public static ComponentMetadata Metadata => new() + { + Name = "accordion", + DisplayName = "Accordion", + Description = "Stack of expandable sections - use with AccordionItem + AccordionTrigger + AccordionContent", + Category = ComponentCategory.Layout, + FilePath = "Accordion.cs", + Dependencies = new List { "element-extensions", "accordion-item", "accordion-trigger", "accordion-content" }, + Variants = new List { "single", "multiple" }, + Tags = new List { "layout", "accordion", "disclosure", "expand" } + }; + + public static IReadOnlyDictionary Contents { get; } = new Dictionary + { + [NativePlatform.MAUI] = @"namespace YourProjectNamespace.Components.UI; + +public enum AccordionType { Single, Multiple } + +// Usage: +// +// ...... +// ... +// +public partial class Accordion : VerticalStackLayout +{ + public static readonly BindableProperty TypeProperty = + BindableProperty.Create(nameof(Type), typeof(AccordionType), typeof(Accordion), AccordionType.Single); + + public AccordionType Type + { + get => (AccordionType)GetValue(TypeProperty); + set => SetValue(TypeProperty, value); + } + + // Fired when any item's open state changes. (value, isOpen) — enough for consumers + // to persist selection or lazy-load content. + public event EventHandler<(string Value, bool IsOpen)>? ItemToggled; + + private readonly HashSet _openValues = new(); + + public Accordion() + { + Spacing = 0; + } + + public bool IsOpen(string value) => _openValues.Contains(value); + + public void Toggle(string value) + { + var wasOpen = _openValues.Contains(value); + + if (Type == AccordionType.Single) + { + // Close every other item — collect first so we can announce them. + var closing = _openValues.Where(v => v != value).ToList(); + _openValues.Clear(); + foreach (var v in closing) NotifyItem(v, false); + } + + if (wasOpen) _openValues.Remove(value); + else _openValues.Add(value); + NotifyItem(value, !wasOpen); + } + + private void NotifyItem(string value, bool isOpen) + { + ItemToggled?.Invoke(this, (value, isOpen)); + } +} +" + }; +} diff --git a/src/ShellUI.Native.Templates/Templates/AccordionTriggerTemplate.cs b/src/ShellUI.Native.Templates/Templates/AccordionTriggerTemplate.cs new file mode 100644 index 0000000..6184e39 --- /dev/null +++ b/src/ShellUI.Native.Templates/Templates/AccordionTriggerTemplate.cs @@ -0,0 +1,34 @@ +using ShellUI.Native.Core.Models; + +namespace ShellUI.Native.Templates.Templates; + +public static class AccordionTriggerTemplate +{ + public static ComponentMetadata Metadata => new() + { + Name = "accordion-trigger", + DisplayName = "Accordion Trigger", + Description = "Tap-to-toggle handle for the enclosing AccordionItem", + Category = ComponentCategory.Layout, + FilePath = "AccordionTrigger.cs", + Dependencies = new List { "element-extensions" }, + Tags = new List { "layout", "accordion", "trigger" } + }; + + public static IReadOnlyDictionary Contents { get; } = new Dictionary + { + [NativePlatform.MAUI] = @"namespace YourProjectNamespace.Components.UI; + +public partial class AccordionTrigger : ContentView +{ + public AccordionTrigger() + { + MinimumHeightRequest = 40; + var tap = new TapGestureRecognizer(); + tap.Tapped += (s, e) => this.FindParentOfType()?.Toggle(); + GestureRecognizers.Add(tap); + } +} +" + }; +} diff --git a/src/ShellUI.Native.Templates/Templates/BreadcrumbItemTemplate.cs b/src/ShellUI.Native.Templates/Templates/BreadcrumbItemTemplate.cs new file mode 100644 index 0000000..46ab37a --- /dev/null +++ b/src/ShellUI.Native.Templates/Templates/BreadcrumbItemTemplate.cs @@ -0,0 +1,82 @@ +using ShellUI.Native.Core.Models; + +namespace ShellUI.Native.Templates.Templates; + +public static class BreadcrumbItemTemplate +{ + public static ComponentMetadata Metadata => new() + { + Name = "breadcrumb-item", + DisplayName = "Breadcrumb Item", + Description = "One crumb in a Breadcrumb - text + tap event. Renders its own trailing separator (hidden on the last item)", + Category = ComponentCategory.Navigation, + FilePath = "BreadcrumbItem.cs", + Dependencies = new List(), + Tags = new List { "navigation", "breadcrumb", "item" } + }; + + public static IReadOnlyDictionary Contents { get; } = new Dictionary + { + [NativePlatform.MAUI] = @"namespace YourProjectNamespace.Components.UI; + +public partial class BreadcrumbItem : ContentView +{ + public static readonly BindableProperty TextProperty = + BindableProperty.Create(nameof(Text), typeof(string), typeof(BreadcrumbItem), string.Empty, + propertyChanged: (b, o, n) => (b as BreadcrumbItem)?.UpdateVisualState()); + + public static readonly BindableProperty IsCurrentProperty = + BindableProperty.Create(nameof(IsCurrent), typeof(bool), typeof(BreadcrumbItem), false, + propertyChanged: (b, o, n) => (b as BreadcrumbItem)?.UpdateVisualState()); + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + // IsCurrent=true renders as the active page (bolder, foreground color, no tap effect). + public bool IsCurrent + { + get => (bool)GetValue(IsCurrentProperty); + set => SetValue(IsCurrentProperty, value); + } + + public event EventHandler? Clicked; + + private readonly Label _text; + private readonly Label _separator; + + public BreadcrumbItem() + { + _text = new Label { FontSize = 14, VerticalOptions = LayoutOptions.Center }; + _separator = new Label + { + Text = ""/"", + FontSize = 14, + TextColor = Color.FromArgb(""#9CA3AF""), + VerticalOptions = LayoutOptions.Center, + Margin = new Thickness(8, 0) + }; + Content = new HorizontalStackLayout { Spacing = 0, Children = { _text, _separator } }; + + var tap = new TapGestureRecognizer(); + tap.Tapped += (s, e) => { if (!IsCurrent) Clicked?.Invoke(this, EventArgs.Empty); }; + GestureRecognizers.Add(tap); + + UpdateVisualState(); + } + + // Called by parent Breadcrumb — the last item hides its trailing separator. + public void SetSeparatorVisible(bool visible) => _separator.IsVisible = visible; + + private void UpdateVisualState() + { + _text.Text = Text ?? string.Empty; + _text.TextColor = IsCurrent ? Color.FromArgb(""#1F2937"") : Color.FromArgb(""#6B7280""); + _text.FontAttributes = IsCurrent ? FontAttributes.Bold : FontAttributes.None; + } +} +" + }; +} diff --git a/src/ShellUI.Native.Templates/Templates/BreadcrumbTemplate.cs b/src/ShellUI.Native.Templates/Templates/BreadcrumbTemplate.cs new file mode 100644 index 0000000..ca106e1 --- /dev/null +++ b/src/ShellUI.Native.Templates/Templates/BreadcrumbTemplate.cs @@ -0,0 +1,53 @@ +using ShellUI.Native.Core.Models; + +namespace ShellUI.Native.Templates.Templates; + +// Breadcrumb - HStack of BreadcrumbItems. The parent tells each item whether it's the +// last, and the item hides its own trailing separator accordingly (each item owns its +// separator so we never mutate the child list from OnChildAdded). +public static class BreadcrumbTemplate +{ + public static ComponentMetadata Metadata => new() + { + Name = "breadcrumb", + DisplayName = "Breadcrumb", + Description = "Navigation trail - contains BreadcrumbItems separated by a slash", + Category = ComponentCategory.Navigation, + FilePath = "Breadcrumb.cs", + Dependencies = new List { "breadcrumb-item" }, + Tags = new List { "navigation", "breadcrumb" } + }; + + public static IReadOnlyDictionary Contents { get; } = new Dictionary + { + [NativePlatform.MAUI] = @"namespace YourProjectNamespace.Components.UI; + +public partial class Breadcrumb : HorizontalStackLayout +{ + public Breadcrumb() + { + Spacing = 0; + } + + protected override void OnChildAdded(Element child) + { + base.OnChildAdded(child); + Dispatcher.Dispatch(RefreshItemSeparators); + } + + protected override void OnChildRemoved(Element child, int oldLogicalIndex) + { + base.OnChildRemoved(child, oldLogicalIndex); + Dispatcher.Dispatch(RefreshItemSeparators); + } + + private void RefreshItemSeparators() + { + var items = Children.OfType().ToList(); + for (int i = 0; i < items.Count; i++) + items[i].SetSeparatorVisible(i < items.Count - 1); + } +} +" + }; +} diff --git a/src/ShellUI.Native.Templates/Templates/ScrollAreaTemplate.cs b/src/ShellUI.Native.Templates/Templates/ScrollAreaTemplate.cs new file mode 100644 index 0000000..49fd8d9 --- /dev/null +++ b/src/ShellUI.Native.Templates/Templates/ScrollAreaTemplate.cs @@ -0,0 +1,36 @@ +using ShellUI.Native.Core.Models; + +namespace ShellUI.Native.Templates.Templates; + +// ScrollArea - thin ScrollView subclass. Exists so consumers get a consistent named +// component and future scrollbar-styling / fade-edge work has a hook. Deliberately +// minimal at v1 — no visual overhead beyond MAUI's native ScrollView. +public static class ScrollAreaTemplate +{ + public static ComponentMetadata Metadata => new() + { + Name = "scroll-area", + DisplayName = "Scroll Area", + Description = "ScrollView with consistent defaults. Thin wrapper at v1 - hook for future scrollbar styling", + Category = ComponentCategory.Layout, + FilePath = "ScrollArea.cs", + Dependencies = new List(), + Variants = new List { "vertical", "horizontal", "both" }, + Tags = new List { "layout", "scroll", "container" } + }; + + public static IReadOnlyDictionary Contents { get; } = new Dictionary + { + [NativePlatform.MAUI] = @"namespace YourProjectNamespace.Components.UI; + +// Usage: ... +public partial class ScrollArea : ScrollView +{ + public ScrollArea() + { + Orientation = ScrollOrientation.Vertical; + } +} +" + }; +} diff --git a/src/ShellUI.Native.Templates/Templates/SkeletonTemplate.cs b/src/ShellUI.Native.Templates/Templates/SkeletonTemplate.cs new file mode 100644 index 0000000..da8bd9c --- /dev/null +++ b/src/ShellUI.Native.Templates/Templates/SkeletonTemplate.cs @@ -0,0 +1,73 @@ +using ShellUI.Native.Core.Models; + +namespace ShellUI.Native.Templates.Templates; + +// Skeleton - pulsing grey placeholder for loading states. +public static class SkeletonTemplate +{ + public static ComponentMetadata Metadata => new() + { + Name = "skeleton", + DisplayName = "Skeleton", + Description = "Loading placeholder - a pulsing grey block sized by parent. Use in lists while data loads", + Category = ComponentCategory.Feedback, + FilePath = "Skeleton.cs", + Dependencies = new List(), + Tags = new List { "feedback", "skeleton", "loading", "placeholder" } + }; + + public static IReadOnlyDictionary Contents { get; } = new Dictionary + { + [NativePlatform.MAUI] = @"using Microsoft.Maui.Controls.Shapes; +namespace YourProjectNamespace.Components.UI; + +public partial class Skeleton : ContentView +{ + public static readonly BindableProperty CornerRadiusProperty = + BindableProperty.Create(nameof(CornerRadius), typeof(double), typeof(Skeleton), 4.0, + propertyChanged: (b, o, n) => (b as Skeleton)?.UpdateShape()); + + public double CornerRadius + { + get => (double)GetValue(CornerRadiusProperty); + set => SetValue(CornerRadiusProperty, value); + } + + private readonly Border _box; + + public Skeleton() + { + HeightRequest = 20; + _box = new Border + { + BackgroundColor = Color.FromArgb(""#E5E7EB""), + StrokeThickness = 0, + StrokeShape = new RoundRectangle { CornerRadius = 4 } + }; + Content = _box; + StartPulse(); + } + + protected override void OnParentChanged() + { + base.OnParentChanged(); + // Restart the animation on reparenting so it survives navigation between pages. + if (Parent != null) StartPulse(); + else this.AbortAnimation(""pulse""); + } + + private void StartPulse() + { + // Opacity loop between 1.0 and 0.5 — read-only visual, no timing side effects. + var anim = new Animation(v => _box.Opacity = v, 1.0, 0.5, Easing.SinInOut); + anim.Commit(this, ""pulse"", 16, 1200, Easing.SinInOut, finished: null, repeat: () => true); + } + + private void UpdateShape() + { + _box.StrokeShape = new RoundRectangle { CornerRadius = (float)CornerRadius }; + } +} +" + }; +} diff --git a/src/ShellUI.Native.Templates/Templates/TabsContentTemplate.cs b/src/ShellUI.Native.Templates/Templates/TabsContentTemplate.cs new file mode 100644 index 0000000..ef7a195 --- /dev/null +++ b/src/ShellUI.Native.Templates/Templates/TabsContentTemplate.cs @@ -0,0 +1,66 @@ +using ShellUI.Native.Core.Models; + +namespace ShellUI.Native.Templates.Templates; + +public static class TabsContentTemplate +{ + public static ComponentMetadata Metadata => new() + { + Name = "tabs-content", + DisplayName = "Tabs Content", + Description = "Body of one tab - shown when parent Tabs.Value matches this Content's Value", + Category = ComponentCategory.Navigation, + FilePath = "TabsContent.cs", + Dependencies = new List { "element-extensions" }, + Tags = new List { "navigation", "tabs", "content" } + }; + + public static IReadOnlyDictionary Contents { get; } = new Dictionary + { + [NativePlatform.MAUI] = @"namespace YourProjectNamespace.Components.UI; + +[ContentProperty(nameof(Content))] +public partial class TabsContent : ContentView +{ + public static readonly BindableProperty ValueProperty = + BindableProperty.Create(nameof(Value), typeof(string), typeof(TabsContent), string.Empty, + propertyChanged: (b, o, n) => (b as TabsContent)?.RefreshVisibility()); + + public string Value + { + get => (string)GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + private Tabs? _parent; + + public TabsContent() + { + IsVisible = false; + } + + protected override void OnParentSet() + { + base.OnParentSet(); + + if (_parent != null) + _parent.ValueChanged -= OnParentValueChanged; + + _parent = this.FindParentOfType(); + if (_parent != null) + { + _parent.ValueChanged += OnParentValueChanged; + RefreshVisibility(); + } + } + + private void OnParentValueChanged(object? sender, (string Old, string New) e) => RefreshVisibility(); + + private void RefreshVisibility() + { + IsVisible = _parent != null && _parent.Value == Value; + } +} +" + }; +} diff --git a/src/ShellUI.Native.Templates/Templates/TabsListTemplate.cs b/src/ShellUI.Native.Templates/Templates/TabsListTemplate.cs new file mode 100644 index 0000000..f387472 --- /dev/null +++ b/src/ShellUI.Native.Templates/Templates/TabsListTemplate.cs @@ -0,0 +1,33 @@ +using ShellUI.Native.Core.Models; + +namespace ShellUI.Native.Templates.Templates; + +// TabsList - horizontal container for TabsTrigger buttons. Thin wrapper; exists so the +// XAML reads naturally and future variants (vertical tabs, pill style) have a hook. +public static class TabsListTemplate +{ + public static ComponentMetadata Metadata => new() + { + Name = "tabs-list", + DisplayName = "Tabs List", + Description = "Horizontal row of TabsTriggers - place inside Tabs", + Category = ComponentCategory.Navigation, + FilePath = "TabsList.cs", + Dependencies = new List(), + Tags = new List { "navigation", "tabs", "list" } + }; + + public static IReadOnlyDictionary Contents { get; } = new Dictionary + { + [NativePlatform.MAUI] = @"namespace YourProjectNamespace.Components.UI; + +public partial class TabsList : HorizontalStackLayout +{ + public TabsList() + { + Spacing = 0; + } +} +" + }; +} diff --git a/src/ShellUI.Native.Templates/Templates/TabsTemplate.cs b/src/ShellUI.Native.Templates/Templates/TabsTemplate.cs new file mode 100644 index 0000000..45b2577 --- /dev/null +++ b/src/ShellUI.Native.Templates/Templates/TabsTemplate.cs @@ -0,0 +1,62 @@ +using ShellUI.Native.Core.Models; + +namespace ShellUI.Native.Templates.Templates; + +// Tabs - one active tab at a time. Compositional parent for TabsList + TabsTrigger + TabsContent. +public static class TabsTemplate +{ + public static ComponentMetadata Metadata => new() + { + Name = "tabs", + DisplayName = "Tabs", + Description = "Tabbed navigation - use with TabsList + TabsTrigger + TabsContent", + Category = ComponentCategory.Navigation, + FilePath = "Tabs.cs", + Dependencies = new List { "element-extensions", "tabs-list", "tabs-trigger", "tabs-content" }, + Tags = new List { "navigation", "tabs" } + }; + + public static IReadOnlyDictionary Contents { get; } = new Dictionary + { + [NativePlatform.MAUI] = @"namespace YourProjectNamespace.Components.UI; + +// Usage: +// +// +// +// +// +// ... +// ... +// +public partial class Tabs : VerticalStackLayout +{ + public static readonly BindableProperty ValueProperty = + BindableProperty.Create(nameof(Value), typeof(string), typeof(Tabs), string.Empty, + BindingMode.TwoWay, + propertyChanged: (b, o, n) => (b as Tabs)?.OnValueChanged((string?)o ?? string.Empty, (string?)n ?? string.Empty)); + + public string Value + { + get => (string)GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + // (oldValue, newValue). Triggers/Content subscribe to update their active state. + public event EventHandler<(string Old, string New)>? ValueChanged; + + public Tabs() + { + Spacing = 0; + } + + public void SetValue(string value) + { + if (Value != value) Value = value; + } + + private void OnValueChanged(string oldValue, string newValue) => ValueChanged?.Invoke(this, (oldValue, newValue)); +} +" + }; +} diff --git a/src/ShellUI.Native.Templates/Templates/TabsTriggerTemplate.cs b/src/ShellUI.Native.Templates/Templates/TabsTriggerTemplate.cs new file mode 100644 index 0000000..3cea7d6 --- /dev/null +++ b/src/ShellUI.Native.Templates/Templates/TabsTriggerTemplate.cs @@ -0,0 +1,106 @@ +using ShellUI.Native.Core.Models; + +namespace ShellUI.Native.Templates.Templates; + +// TabsTrigger - tap sets the parent Tabs' Value. Renders its own Text label with an +// active-state underline (Primary token) — no separate child needed. +public static class TabsTriggerTemplate +{ + public static ComponentMetadata Metadata => new() + { + Name = "tabs-trigger", + DisplayName = "Tabs Trigger", + Description = "One tab button. Place inside TabsList", + Category = ComponentCategory.Navigation, + FilePath = "TabsTrigger.cs", + Dependencies = new List { "element-extensions" }, + Tags = new List { "navigation", "tabs", "trigger" } + }; + + public static IReadOnlyDictionary Contents { get; } = new Dictionary + { + [NativePlatform.MAUI] = @"namespace YourProjectNamespace.Components.UI; + +public partial class TabsTrigger : ContentView +{ + public static readonly BindableProperty ValueProperty = + BindableProperty.Create(nameof(Value), typeof(string), typeof(TabsTrigger), string.Empty); + + public static readonly BindableProperty TextProperty = + BindableProperty.Create(nameof(Text), typeof(string), typeof(TabsTrigger), string.Empty, + propertyChanged: (b, o, n) => (b as TabsTrigger)?.UpdateLabel()); + + public string Value + { + get => (string)GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + private readonly Label _label; + private readonly BoxView _underline; + private Tabs? _parent; + + public TabsTrigger() + { + MinimumHeightRequest = 40; + + _label = new Label + { + FontSize = 14, + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center, + Padding = new Thickness(12, 8) + }; + _underline = new BoxView + { + HeightRequest = 2, + Color = Colors.Transparent, + HorizontalOptions = LayoutOptions.Fill + }; + Content = new VerticalStackLayout + { + Spacing = 0, + Children = { _label, _underline } + }; + + var tap = new TapGestureRecognizer(); + tap.Tapped += (s, e) => _parent?.SetValue(Value); + GestureRecognizers.Add(tap); + } + + protected override void OnParentSet() + { + base.OnParentSet(); + + if (_parent != null) + _parent.ValueChanged -= OnParentValueChanged; + + _parent = this.FindParentOfType(); + if (_parent != null) + { + _parent.ValueChanged += OnParentValueChanged; + UpdateActiveState(_parent.Value); + } + } + + private void OnParentValueChanged(object? sender, (string Old, string New) e) => UpdateActiveState(e.New); + + private void UpdateActiveState(string activeValue) + { + var isActive = activeValue == Value; + _label.TextColor = isActive ? Color.FromArgb(""#2563EB"") : Color.FromArgb(""#6B7280""); + _label.FontAttributes = isActive ? FontAttributes.Bold : FontAttributes.None; + _underline.Color = isActive ? Color.FromArgb(""#2563EB"") : Colors.Transparent; + } + + private void UpdateLabel() => _label.Text = Text ?? string.Empty; +} +" + }; +} From 79f54823b65d4aef6b443ae3eb72b945c3c38701 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 29 Aug 2026 22:11:14 +0200 Subject: [PATCH 03/20] demo(p3): add MAUI.Demo sections + install copies for the P3 tier Copies the 15 new P3 templates into examples/MAUI.Demo/Components/UI/ (YourProjectNamespace -> MAUI.Demo) and adds demo sections to MainPage.xaml covering every family: - Collapsible with a Button trigger + Card content - Accordion (Single mode) with three items separated by ui:Separator - Tabs with three panels (overview / details / activity) - Breadcrumb: Home > Library > Data > Current Page (IsCurrent on last) - Skeleton: four blocks of varying size + CornerRadius=8 on one - ScrollArea: 10-line vertical scroll at HeightRequest=140 Wires OnBreadcrumbClicked handler in MainPage.xaml.cs. Fulfills Phase 1c exit criterion #3 (every new component has a demo section). Also updates docs/COMPONENTS.md: - Skeleton section replaces the "planned" placeholder - New "Navigation Components" section with usage snippets for Tabs, Accordion, Collapsible, Breadcrumb, ScrollArea (Collapsible sits here alongside its composite parent Accordion for discoverability) - Auto-install table extended with the 5 new parent -> family mappings - Component Categories updated: adds Navigation category, moves Skeleton under Feedback, moves Collapsible/Accordion/ScrollArea into Layout Records the installs in examples/MAUI.Demo/shellui-native.json so `shellui-native list` in the demo reflects reality. Co-Authored-By: Claude Opus 4.7 --- docs/COMPONENTS.md | 190 +++++++++++++++++- examples/MAUI.Demo/Components/UI/Accordion.cs | 55 +++++ .../Components/UI/AccordionContent.cs | 45 +++++ .../MAUI.Demo/Components/UI/AccordionItem.cs | 55 +++++ .../Components/UI/AccordionTrigger.cs | 12 ++ .../MAUI.Demo/Components/UI/Breadcrumb.cs | 28 +++ .../MAUI.Demo/Components/UI/BreadcrumbItem.cs | 60 ++++++ .../MAUI.Demo/Components/UI/Collapsible.cs | 37 ++++ .../Components/UI/CollapsibleContent.cs | 52 +++++ .../Components/UI/CollapsibleTrigger.cs | 20 ++ .../MAUI.Demo/Components/UI/ScrollArea.cs | 10 + examples/MAUI.Demo/Components/UI/Skeleton.cs | 50 +++++ examples/MAUI.Demo/Components/UI/Tabs.cs | 39 ++++ .../MAUI.Demo/Components/UI/TabsContent.cs | 44 ++++ examples/MAUI.Demo/Components/UI/TabsList.cs | 9 + .../MAUI.Demo/Components/UI/TabsTrigger.cs | 82 ++++++++ examples/MAUI.Demo/MainPage.xaml | 103 +++++++++- examples/MAUI.Demo/MainPage.xaml.cs | 6 + examples/MAUI.Demo/shellui-native.json | 105 ++++++++++ 19 files changed, 996 insertions(+), 6 deletions(-) create mode 100644 examples/MAUI.Demo/Components/UI/Accordion.cs create mode 100644 examples/MAUI.Demo/Components/UI/AccordionContent.cs create mode 100644 examples/MAUI.Demo/Components/UI/AccordionItem.cs create mode 100644 examples/MAUI.Demo/Components/UI/AccordionTrigger.cs create mode 100644 examples/MAUI.Demo/Components/UI/Breadcrumb.cs create mode 100644 examples/MAUI.Demo/Components/UI/BreadcrumbItem.cs create mode 100644 examples/MAUI.Demo/Components/UI/Collapsible.cs create mode 100644 examples/MAUI.Demo/Components/UI/CollapsibleContent.cs create mode 100644 examples/MAUI.Demo/Components/UI/CollapsibleTrigger.cs create mode 100644 examples/MAUI.Demo/Components/UI/ScrollArea.cs create mode 100644 examples/MAUI.Demo/Components/UI/Skeleton.cs create mode 100644 examples/MAUI.Demo/Components/UI/Tabs.cs create mode 100644 examples/MAUI.Demo/Components/UI/TabsContent.cs create mode 100644 examples/MAUI.Demo/Components/UI/TabsList.cs create mode 100644 examples/MAUI.Demo/Components/UI/TabsTrigger.cs diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 784d3b6..7ed7eb4 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -389,7 +389,29 @@ shellui-native add progress --- ### Skeleton -*(Planned — P3.6, see [COMPONENTS_ROADMAP.md](./COMPONENTS_ROADMAP.md))* — Loading placeholder animation. +Pulsing grey placeholder for loading states. + +```bash +shellui-native add skeleton +``` + +**Properties:** +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| CornerRadius | double | 4 | Rounded corner radius | +| HeightRequest / WidthRequest | double | 20 / — | Size — set to match the content it stands in for | + +Opacity animates on a 1200ms sin-loop between 1.0 and 0.5. The animation restarts on +reparenting so it survives navigation between pages. + +**Usage:** +```xml + + + + + +``` --- @@ -420,6 +442,161 @@ shellui-native add alert --- +## Navigation Components + +Tabs, accordion, breadcrumb. Also compositional: parent owns state, triggers set it, content reacts. + +### Tabs +Tabbed navigation — one panel visible at a time. + +```bash +shellui-native add tabs +``` + +Auto-installs `tabs-list`, `tabs-trigger`, `tabs-content`, and `element-extensions`. + +**Properties (Tabs):** +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| Value | string | "" | Active tab id (two-way bindable). `SetValue(...)` also works. | + +**Properties (TabsTrigger):** `Value` (string, required — id it activates), `Text` (string, its label). Renders active state with Primary underline + bold; MinimumHeightRequest = 40 per the Form Sizing Contract. + +**Properties (TabsContent):** `Value` (string, required — panel visible when `Tabs.Value` matches). + +**Usage:** +```xml + + + + + + + + +``` + +Content swaps instantly (no fade) — rapid tab switches would look glitchy under an overlapping animation. Use `Collapsible` or `Accordion` when you want the reveal animation. + +--- + +### Accordion +Stack of expandable sections. + +```bash +shellui-native add accordion +``` + +Auto-installs `accordion-item`, `accordion-trigger`, `accordion-content`, `element-extensions`. + +**Properties (Accordion):** +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| Type | AccordionType | Single | `Single` (only one item open — radio-style) or `Multiple` (any combination) | + +**Events (Accordion):** `ItemToggled(value, isOpen)` — fired for every open + close, so in `Single` mode you'll see a close event for the previously open item followed by an open event for the new one. Enough to persist selection or trigger lazy loading. + +**Properties (AccordionItem):** `Value` (string id — required for state tracking). + +**Usage:** +```xml + + + + + + + + + + + +``` + +Content fades over 150ms + toggles `IsVisible` (same primitive as `Collapsible`). Height animation is deliberately avoided — MAUI's cross-platform height animation needs measured child height and is unreliable. + +--- + +### Collapsible +Single expand/collapse — the primitive `AccordionItem` composes on top of. + +```bash +shellui-native add collapsible +``` + +Auto-installs `collapsible-trigger`, `collapsible-content`, `element-extensions`. + +**Properties (Collapsible):** +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| Open | bool | false | Expanded state. `SetOpen(bool)` and `Toggle()` also work. | + +**Events (Collapsible):** `OpenChanged(bool)` — fires on every flip. + +**Usage:** +```xml + + + + + + + +``` + +`CollapsibleTrigger` wraps whatever visible handle you want — a Label, an icon, a whole row. It only adds the tap gesture + the 40px MinimumHeightRequest (Form Sizing Contract). + +--- + +### Breadcrumb +Navigation trail with `/` separators. + +```bash +shellui-native add breadcrumb +``` + +Auto-installs `breadcrumb-item`. + +**Properties (BreadcrumbItem):** +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| Text | string | "" | Crumb text | +| IsCurrent | bool | false | Renders bold + foreground color; suppresses tap | + +**Events (BreadcrumbItem):** `Clicked` — fires on tap unless `IsCurrent`. + +Each item owns its trailing separator; the parent `Breadcrumb` hides the last one automatically as items are added or removed. + +**Usage:** +```xml + + + + + +``` + +--- + +### ScrollArea +Thin `ScrollView` subclass. A named hook for future scrollbar styling — visually identical to MAUI's native `ScrollView` at v1. + +```bash +shellui-native add scroll-area +``` + +**Usage:** +```xml + + + + + +``` + +--- + ## Overlay Components Modal dialogs, drawers, sheets, dropdowns, and popovers. Use compositional pattern: parent + trigger + content. @@ -565,16 +742,21 @@ When you add a component, its dependencies are automatically installed: | sheet | element-extensions, sheet-trigger, sheet-content | | dropdown | element-extensions, dropdown-trigger, dropdown-content, dropdown-item | | popover | element-extensions, popover-trigger, popover-content | +| collapsible | element-extensions, collapsible-trigger, collapsible-content | +| accordion | element-extensions, accordion-item, accordion-trigger, accordion-content | +| tabs | element-extensions, tabs-list, tabs-trigger, tabs-content | +| breadcrumb | breadcrumb-item | ## Component Categories Components are organized into the following categories: - **Form Components**: `button`, `input`, `label`, `checkbox`, `switch`, `textarea`, `slider`, `select`, `radio-group` (+ `radio-group-item`), `date-picker`, `time-picker` -- **Layout Components**: `card` (+ `card-header`, `card-content`, `card-footer`), `separator` +- **Layout Components**: `card` (+ `card-header`, `card-content`, `card-footer`), `separator`, `collapsible` (+ trigger, content), `accordion` (+ item, trigger, content), `scroll-area` +- **Navigation**: `tabs` (+ list, trigger, content), `breadcrumb` (+ item) - **Data Display**: `badge`, `progress` -- **Feedback**: `alert` +- **Feedback**: `alert`, `skeleton` - **Overlay**: `dialog`, `drawer`, `sheet`, `dropdown`, `popover` (+ their trigger/content sub-components) - **Utility**: `shell`, `element-extensions` (auto-installed by overlay components) -For P3+ components (Tabs, Accordion, Tooltip, Toast, Skeleton, Table, etc.) see [COMPONENTS_ROADMAP.md](./COMPONENTS_ROADMAP.md). +For P4+ components (Tooltip, Toast, AlertDialog, HoverCard, Table, etc.) see [COMPONENTS_ROADMAP.md](./COMPONENTS_ROADMAP.md). diff --git a/examples/MAUI.Demo/Components/UI/Accordion.cs b/examples/MAUI.Demo/Components/UI/Accordion.cs new file mode 100644 index 0000000..d666f04 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/Accordion.cs @@ -0,0 +1,55 @@ +namespace MAUI.Demo.Components.UI; + +public enum AccordionType { Single, Multiple } + +// Usage: +// +// ...... +// ... +// +public partial class Accordion : VerticalStackLayout +{ + public static readonly BindableProperty TypeProperty = + BindableProperty.Create(nameof(Type), typeof(AccordionType), typeof(Accordion), AccordionType.Single); + + public AccordionType Type + { + get => (AccordionType)GetValue(TypeProperty); + set => SetValue(TypeProperty, value); + } + + // Fired when any item's open state changes. (value, isOpen) — enough for consumers + // to persist selection or lazy-load content. + public event EventHandler<(string Value, bool IsOpen)>? ItemToggled; + + private readonly HashSet _openValues = new(); + + public Accordion() + { + Spacing = 0; + } + + public bool IsOpen(string value) => _openValues.Contains(value); + + public void Toggle(string value) + { + var wasOpen = _openValues.Contains(value); + + if (Type == AccordionType.Single) + { + // Close every other item — collect first so we can announce them. + var closing = _openValues.Where(v => v != value).ToList(); + _openValues.Clear(); + foreach (var v in closing) NotifyItem(v, false); + } + + if (wasOpen) _openValues.Remove(value); + else _openValues.Add(value); + NotifyItem(value, !wasOpen); + } + + private void NotifyItem(string value, bool isOpen) + { + ItemToggled?.Invoke(this, (value, isOpen)); + } +} diff --git a/examples/MAUI.Demo/Components/UI/AccordionContent.cs b/examples/MAUI.Demo/Components/UI/AccordionContent.cs new file mode 100644 index 0000000..7a25507 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/AccordionContent.cs @@ -0,0 +1,45 @@ +namespace MAUI.Demo.Components.UI; + +[ContentProperty(nameof(Content))] +public partial class AccordionContent : ContentView +{ + private AccordionItem? _item; + + public AccordionContent() + { + IsVisible = false; + Opacity = 0; + } + + protected override void OnParentSet() + { + base.OnParentSet(); + + if (_item != null) + _item.OpenChanged -= OnItemOpenChanged; + + _item = this.FindParentOfType(); + if (_item != null) + { + _item.OpenChanged += OnItemOpenChanged; + IsVisible = _item.IsOpen; + Opacity = _item.IsOpen ? 1 : 0; + } + } + + private void OnItemOpenChanged(object? sender, bool open) => _ = AnimateAsync(open); + + private async Task AnimateAsync(bool open) + { + if (open) + { + IsVisible = true; + await this.FadeTo(1, 150, Easing.CubicOut); + } + else + { + await this.FadeTo(0, 150, Easing.CubicIn); + IsVisible = false; + } + } +} diff --git a/examples/MAUI.Demo/Components/UI/AccordionItem.cs b/examples/MAUI.Demo/Components/UI/AccordionItem.cs new file mode 100644 index 0000000..94b63c6 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/AccordionItem.cs @@ -0,0 +1,55 @@ +namespace MAUI.Demo.Components.UI; + +public partial class AccordionItem : VerticalStackLayout +{ + public static readonly BindableProperty ValueProperty = + BindableProperty.Create(nameof(Value), typeof(string), typeof(AccordionItem), string.Empty); + + public string Value + { + get => (string)GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + // Item-local view of parent's open set. Trigger/Content subscribe here so they don't + // have to know about the Accordion above. + public bool IsOpen { get; private set; } + public event EventHandler? OpenChanged; + + private Accordion? _parent; + + public AccordionItem() + { + Spacing = 0; + } + + protected override void OnParentSet() + { + base.OnParentSet(); + + if (_parent != null) + _parent.ItemToggled -= OnParentToggled; + + _parent = this.FindParentOfType(); + if (_parent != null) + { + _parent.ItemToggled += OnParentToggled; + var open = _parent.IsOpen(Value); + if (open != IsOpen) + { + IsOpen = open; + OpenChanged?.Invoke(this, IsOpen); + } + } + } + + private void OnParentToggled(object? sender, (string Value, bool IsOpen) e) + { + if (e.Value != Value) return; + if (IsOpen == e.IsOpen) return; + IsOpen = e.IsOpen; + OpenChanged?.Invoke(this, IsOpen); + } + + public void Toggle() => _parent?.Toggle(Value); +} diff --git a/examples/MAUI.Demo/Components/UI/AccordionTrigger.cs b/examples/MAUI.Demo/Components/UI/AccordionTrigger.cs new file mode 100644 index 0000000..73b67fd --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/AccordionTrigger.cs @@ -0,0 +1,12 @@ +namespace MAUI.Demo.Components.UI; + +public partial class AccordionTrigger : ContentView +{ + public AccordionTrigger() + { + MinimumHeightRequest = 40; + var tap = new TapGestureRecognizer(); + tap.Tapped += (s, e) => this.FindParentOfType()?.Toggle(); + GestureRecognizers.Add(tap); + } +} diff --git a/examples/MAUI.Demo/Components/UI/Breadcrumb.cs b/examples/MAUI.Demo/Components/UI/Breadcrumb.cs new file mode 100644 index 0000000..9d72a16 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/Breadcrumb.cs @@ -0,0 +1,28 @@ +namespace MAUI.Demo.Components.UI; + +public partial class Breadcrumb : HorizontalStackLayout +{ + public Breadcrumb() + { + Spacing = 0; + } + + protected override void OnChildAdded(Element child) + { + base.OnChildAdded(child); + Dispatcher.Dispatch(RefreshItemSeparators); + } + + protected override void OnChildRemoved(Element child, int oldLogicalIndex) + { + base.OnChildRemoved(child, oldLogicalIndex); + Dispatcher.Dispatch(RefreshItemSeparators); + } + + private void RefreshItemSeparators() + { + var items = Children.OfType().ToList(); + for (int i = 0; i < items.Count; i++) + items[i].SetSeparatorVisible(i < items.Count - 1); + } +} diff --git a/examples/MAUI.Demo/Components/UI/BreadcrumbItem.cs b/examples/MAUI.Demo/Components/UI/BreadcrumbItem.cs new file mode 100644 index 0000000..24cf7d0 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/BreadcrumbItem.cs @@ -0,0 +1,60 @@ +namespace MAUI.Demo.Components.UI; + +public partial class BreadcrumbItem : ContentView +{ + public static readonly BindableProperty TextProperty = + BindableProperty.Create(nameof(Text), typeof(string), typeof(BreadcrumbItem), string.Empty, + propertyChanged: (b, o, n) => (b as BreadcrumbItem)?.UpdateVisualState()); + + public static readonly BindableProperty IsCurrentProperty = + BindableProperty.Create(nameof(IsCurrent), typeof(bool), typeof(BreadcrumbItem), false, + propertyChanged: (b, o, n) => (b as BreadcrumbItem)?.UpdateVisualState()); + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + // IsCurrent=true renders as the active page (bolder, foreground color, no tap effect). + public bool IsCurrent + { + get => (bool)GetValue(IsCurrentProperty); + set => SetValue(IsCurrentProperty, value); + } + + public event EventHandler? Clicked; + + private readonly Label _text; + private readonly Label _separator; + + public BreadcrumbItem() + { + _text = new Label { FontSize = 14, VerticalOptions = LayoutOptions.Center }; + _separator = new Label + { + Text = "/", + FontSize = 14, + TextColor = Color.FromArgb("#9CA3AF"), + VerticalOptions = LayoutOptions.Center, + Margin = new Thickness(8, 0) + }; + Content = new HorizontalStackLayout { Spacing = 0, Children = { _text, _separator } }; + + var tap = new TapGestureRecognizer(); + tap.Tapped += (s, e) => { if (!IsCurrent) Clicked?.Invoke(this, EventArgs.Empty); }; + GestureRecognizers.Add(tap); + + UpdateVisualState(); + } + + // Called by parent Breadcrumb — the last item hides its trailing separator. + public void SetSeparatorVisible(bool visible) => _separator.IsVisible = visible; + + private void UpdateVisualState() + { + _text.Text = Text ?? string.Empty; + _text.TextColor = IsCurrent ? Color.FromArgb("#1F2937") : Color.FromArgb("#6B7280"); + _text.FontAttributes = IsCurrent ? FontAttributes.Bold : FontAttributes.None; + } +} diff --git a/examples/MAUI.Demo/Components/UI/Collapsible.cs b/examples/MAUI.Demo/Components/UI/Collapsible.cs new file mode 100644 index 0000000..b53d504 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/Collapsible.cs @@ -0,0 +1,37 @@ +namespace MAUI.Demo.Components.UI; + +// Compositional expand/collapse. Usage: +// +// +// +// +public partial class Collapsible : VerticalStackLayout +{ + public static readonly BindableProperty OpenProperty = + BindableProperty.Create(nameof(Open), typeof(bool), typeof(Collapsible), false, + propertyChanged: (b, o, n) => (b as Collapsible)?.OnOpenChanged()); + + public bool Open + { + get => (bool)GetValue(OpenProperty); + set => SetValue(OpenProperty, value); + } + + // Raised whenever Open flips. CollapsibleContent subscribes to drive its visibility; + // consumers may also subscribe for side effects (analytics, lazy loading, etc.). + public event EventHandler? OpenChanged; + + public Collapsible() + { + Spacing = 0; + } + + public void SetOpen(bool value) + { + if (Open != value) Open = value; + } + + public void Toggle() => SetOpen(!Open); + + private void OnOpenChanged() => OpenChanged?.Invoke(this, Open); +} diff --git a/examples/MAUI.Demo/Components/UI/CollapsibleContent.cs b/examples/MAUI.Demo/Components/UI/CollapsibleContent.cs new file mode 100644 index 0000000..704b4d4 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/CollapsibleContent.cs @@ -0,0 +1,52 @@ +namespace MAUI.Demo.Components.UI; + +[ContentProperty(nameof(Content))] +public partial class CollapsibleContent : ContentView +{ + private Collapsible? _parent; + + public CollapsibleContent() + { + IsVisible = false; + Opacity = 0; + } + + protected override void OnParentSet() + { + base.OnParentSet(); + + // Detach from any previous parent to avoid duplicate handlers if the view is reparented. + if (_parent != null) + _parent.OpenChanged -= OnParentOpenChanged; + + _parent = this.FindParentOfType(); + if (_parent != null) + { + _parent.OpenChanged += OnParentOpenChanged; + ApplyState(_parent.Open, animate: false); + } + } + + private void OnParentOpenChanged(object? sender, bool open) => _ = AnimateAsync(open); + + private async Task AnimateAsync(bool open) + { + if (open) + { + IsVisible = true; + await this.FadeTo(1, 150, Easing.CubicOut); + } + else + { + await this.FadeTo(0, 150, Easing.CubicIn); + IsVisible = false; + } + } + + private void ApplyState(bool open, bool animate) + { + if (animate) { _ = AnimateAsync(open); return; } + IsVisible = open; + Opacity = open ? 1 : 0; + } +} diff --git a/examples/MAUI.Demo/Components/UI/CollapsibleTrigger.cs b/examples/MAUI.Demo/Components/UI/CollapsibleTrigger.cs new file mode 100644 index 0000000..bd37731 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/CollapsibleTrigger.cs @@ -0,0 +1,20 @@ +namespace MAUI.Demo.Components.UI; + +// Tap to toggle. Wrap your visible handle - a Label, an icon, a whole row. +// MinimumHeightRequest = 40 keeps hit target aligned with Input / Select / Button +// (Form Sizing Contract). +public partial class CollapsibleTrigger : ContentView +{ + public CollapsibleTrigger() + { + MinimumHeightRequest = 40; + var tap = new TapGestureRecognizer(); + tap.Tapped += OnTapped; + GestureRecognizers.Add(tap); + } + + private void OnTapped(object? sender, TappedEventArgs e) + { + this.FindParentOfType()?.Toggle(); + } +} diff --git a/examples/MAUI.Demo/Components/UI/ScrollArea.cs b/examples/MAUI.Demo/Components/UI/ScrollArea.cs new file mode 100644 index 0000000..a61ee33 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/ScrollArea.cs @@ -0,0 +1,10 @@ +namespace MAUI.Demo.Components.UI; + +// Usage: ... +public partial class ScrollArea : ScrollView +{ + public ScrollArea() + { + Orientation = ScrollOrientation.Vertical; + } +} diff --git a/examples/MAUI.Demo/Components/UI/Skeleton.cs b/examples/MAUI.Demo/Components/UI/Skeleton.cs new file mode 100644 index 0000000..e3833f8 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/Skeleton.cs @@ -0,0 +1,50 @@ +using Microsoft.Maui.Controls.Shapes; +namespace MAUI.Demo.Components.UI; + +public partial class Skeleton : ContentView +{ + public static readonly BindableProperty CornerRadiusProperty = + BindableProperty.Create(nameof(CornerRadius), typeof(double), typeof(Skeleton), 4.0, + propertyChanged: (b, o, n) => (b as Skeleton)?.UpdateShape()); + + public double CornerRadius + { + get => (double)GetValue(CornerRadiusProperty); + set => SetValue(CornerRadiusProperty, value); + } + + private readonly Border _box; + + public Skeleton() + { + HeightRequest = 20; + _box = new Border + { + BackgroundColor = Color.FromArgb("#E5E7EB"), + StrokeThickness = 0, + StrokeShape = new RoundRectangle { CornerRadius = 4 } + }; + Content = _box; + StartPulse(); + } + + protected override void OnParentChanged() + { + base.OnParentChanged(); + // Restart the animation on reparenting so it survives navigation between pages. + if (Parent != null) StartPulse(); + else this.AbortAnimation("pulse"); + } + + private void StartPulse() + { + // Opacity loop between 1.0 and 0.5 — read-only visual, no timing side effects. + var anim = new Animation(v => _box.Opacity = v, 1.0, 0.5, Easing.SinInOut); + anim.Commit(this, "pulse", 16, 1200, Easing.SinInOut, finished: null, repeat: () => true); + } + + private void UpdateShape() + { + _box.StrokeShape = new RoundRectangle { CornerRadius = (float)CornerRadius }; + } +} diff --git a/examples/MAUI.Demo/Components/UI/Tabs.cs b/examples/MAUI.Demo/Components/UI/Tabs.cs new file mode 100644 index 0000000..5258782 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/Tabs.cs @@ -0,0 +1,39 @@ +namespace MAUI.Demo.Components.UI; + +// Usage: +// +// +// +// +// +// ... +// ... +// +public partial class Tabs : VerticalStackLayout +{ + public static readonly BindableProperty ValueProperty = + BindableProperty.Create(nameof(Value), typeof(string), typeof(Tabs), string.Empty, + BindingMode.TwoWay, + propertyChanged: (b, o, n) => (b as Tabs)?.OnValueChanged((string?)o ?? string.Empty, (string?)n ?? string.Empty)); + + public string Value + { + get => (string)GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + // (oldValue, newValue). Triggers/Content subscribe to update their active state. + public event EventHandler<(string Old, string New)>? ValueChanged; + + public Tabs() + { + Spacing = 0; + } + + public void SetValue(string value) + { + if (Value != value) Value = value; + } + + private void OnValueChanged(string oldValue, string newValue) => ValueChanged?.Invoke(this, (oldValue, newValue)); +} diff --git a/examples/MAUI.Demo/Components/UI/TabsContent.cs b/examples/MAUI.Demo/Components/UI/TabsContent.cs new file mode 100644 index 0000000..110c1a8 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/TabsContent.cs @@ -0,0 +1,44 @@ +namespace MAUI.Demo.Components.UI; + +[ContentProperty(nameof(Content))] +public partial class TabsContent : ContentView +{ + public static readonly BindableProperty ValueProperty = + BindableProperty.Create(nameof(Value), typeof(string), typeof(TabsContent), string.Empty, + propertyChanged: (b, o, n) => (b as TabsContent)?.RefreshVisibility()); + + public string Value + { + get => (string)GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + private Tabs? _parent; + + public TabsContent() + { + IsVisible = false; + } + + protected override void OnParentSet() + { + base.OnParentSet(); + + if (_parent != null) + _parent.ValueChanged -= OnParentValueChanged; + + _parent = this.FindParentOfType(); + if (_parent != null) + { + _parent.ValueChanged += OnParentValueChanged; + RefreshVisibility(); + } + } + + private void OnParentValueChanged(object? sender, (string Old, string New) e) => RefreshVisibility(); + + private void RefreshVisibility() + { + IsVisible = _parent != null && _parent.Value == Value; + } +} diff --git a/examples/MAUI.Demo/Components/UI/TabsList.cs b/examples/MAUI.Demo/Components/UI/TabsList.cs new file mode 100644 index 0000000..c0ad39f --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/TabsList.cs @@ -0,0 +1,9 @@ +namespace MAUI.Demo.Components.UI; + +public partial class TabsList : HorizontalStackLayout +{ + public TabsList() + { + Spacing = 0; + } +} diff --git a/examples/MAUI.Demo/Components/UI/TabsTrigger.cs b/examples/MAUI.Demo/Components/UI/TabsTrigger.cs new file mode 100644 index 0000000..5a4358a --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/TabsTrigger.cs @@ -0,0 +1,82 @@ +namespace MAUI.Demo.Components.UI; + +public partial class TabsTrigger : ContentView +{ + public static readonly BindableProperty ValueProperty = + BindableProperty.Create(nameof(Value), typeof(string), typeof(TabsTrigger), string.Empty); + + public static readonly BindableProperty TextProperty = + BindableProperty.Create(nameof(Text), typeof(string), typeof(TabsTrigger), string.Empty, + propertyChanged: (b, o, n) => (b as TabsTrigger)?.UpdateLabel()); + + public string Value + { + get => (string)GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + private readonly Label _label; + private readonly BoxView _underline; + private Tabs? _parent; + + public TabsTrigger() + { + MinimumHeightRequest = 40; + + _label = new Label + { + FontSize = 14, + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center, + Padding = new Thickness(12, 8) + }; + _underline = new BoxView + { + HeightRequest = 2, + Color = Colors.Transparent, + HorizontalOptions = LayoutOptions.Fill + }; + Content = new VerticalStackLayout + { + Spacing = 0, + Children = { _label, _underline } + }; + + var tap = new TapGestureRecognizer(); + tap.Tapped += (s, e) => _parent?.SetValue(Value); + GestureRecognizers.Add(tap); + } + + protected override void OnParentSet() + { + base.OnParentSet(); + + if (_parent != null) + _parent.ValueChanged -= OnParentValueChanged; + + _parent = this.FindParentOfType(); + if (_parent != null) + { + _parent.ValueChanged += OnParentValueChanged; + UpdateActiveState(_parent.Value); + } + } + + private void OnParentValueChanged(object? sender, (string Old, string New) e) => UpdateActiveState(e.New); + + private void UpdateActiveState(string activeValue) + { + var isActive = activeValue == Value; + _label.TextColor = isActive ? Color.FromArgb("#2563EB") : Color.FromArgb("#6B7280"); + _label.FontAttributes = isActive ? FontAttributes.Bold : FontAttributes.None; + _underline.Color = isActive ? Color.FromArgb("#2563EB") : Colors.Transparent; + } + + private void UpdateLabel() => _label.Text = Text ?? string.Empty; +} diff --git a/examples/MAUI.Demo/MainPage.xaml b/examples/MAUI.Demo/MainPage.xaml index 2eea940..bb021b9 100644 --- a/examples/MAUI.Demo/MainPage.xaml +++ b/examples/MAUI.Demo/MainPage.xaml @@ -256,9 +256,108 @@ + + + + +