diff --git a/src/LumexUI/Components/Bases/LumexBooleanInputBase.cs b/src/LumexUI/Components/Bases/LumexBooleanInputBase.cs new file mode 100644 index 00000000..c3354961 --- /dev/null +++ b/src/LumexUI/Components/Bases/LumexBooleanInputBase.cs @@ -0,0 +1,51 @@ +using System.Diagnostics.CodeAnalysis; + +using Microsoft.AspNetCore.Components; + +namespace LumexUI; + +public abstract class LumexBooleanInputBase : LumexInputBase +{ + /// + /// Gets or sets content to be rendered inside the input. + /// + [Parameter] public RenderFragment? ChildContent { get; set; } + + /// + /// Gets the disabled state of the input. + /// Derived classes can override this to determine the input's disabled state. + /// + /// A value indicating whether the input is disabled. + protected internal virtual bool GetDisabledState() => Disabled; + + /// + /// Gets the readonly state of the input. + /// Derived classes can override this to determine the input's readonly state. + /// + /// A value indicating whether the input is readonly. + protected internal virtual bool GetReadOnlyState() => ReadOnly; + + /// + protected override bool TryParseValueFromString( string? value, [MaybeNullWhen( false )] out bool result ) + { + throw new NotSupportedException( + $"This component does not parse string inputs. " + + $"Bind to the '{nameof( CurrentValue )}' property, not '{nameof( CurrentValueAsString )}'." ); + } + + /// + /// Handles the change event asynchronously. + /// Derived classes can override this to specify custom behavior when the input's value changes. + /// + /// The change event arguments. + /// A representing the asynchronous operation. + protected virtual Task OnChangeAsync( ChangeEventArgs args ) + { + if( GetDisabledState() || GetReadOnlyState() ) + { + return Task.CompletedTask; + } + + return SetCurrentValueAsync( (bool)args.Value! ); + } +} diff --git a/src/LumexUI/Components/Bases/LumexInputBase.cs b/src/LumexUI/Components/Bases/LumexInputBase.cs index 995e81e0..5348e519 100644 --- a/src/LumexUI/Components/Bases/LumexInputBase.cs +++ b/src/LumexUI/Components/Bases/LumexInputBase.cs @@ -34,14 +34,6 @@ public abstract class LumexInputBase : LumexComponentBase /// [Parameter] public ThemeColor Color { get; set; } - /// - /// Gets or sets the border radius of the input. - /// - /// - /// The default is - /// - [Parameter] public Radius Radius { get; set; } = Radius.Medium; - /// /// Gets or sets the size of the input. /// diff --git a/src/LumexUI/Components/Checkbox/LumexCheckbox.razor b/src/LumexUI/Components/Checkbox/LumexCheckbox.razor index 1456553f..48594620 100644 --- a/src/LumexUI/Components/Checkbox/LumexCheckbox.razor +++ b/src/LumexUI/Components/Checkbox/LumexCheckbox.razor @@ -1,17 +1,19 @@ @namespace LumexUI -@inherits LumexInputBase +@inherits LumexBooleanInputBase @@ -40,7 +42,7 @@ private void RenderCheckIcon( RenderTreeBuilder __builder ) { var style = ElementStyle.Empty() - .Add( "transition", "stroke-dashoffset 0.15s linear 0.15s", when: _checked ) + .Add( "transition", "stroke-dashoffset 0.15s linear 0.15s", when: CurrentValue ) .ToString(); @@ -48,11 +50,11 @@ points="1 9 7 14 15 4" stroke="currentColor" stroke-dasharray="22" - stroke-dashoffset="@(_checked ? 44 : 66)" + stroke-dashoffset="@(CurrentValue ? 44 : 66)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" style="@style" /> } -} \ No newline at end of file +} diff --git a/src/LumexUI/Components/Checkbox/LumexCheckbox.razor.cs b/src/LumexUI/Components/Checkbox/LumexCheckbox.razor.cs index 7f4bc9a0..cb553080 100644 --- a/src/LumexUI/Components/Checkbox/LumexCheckbox.razor.cs +++ b/src/LumexUI/Components/Checkbox/LumexCheckbox.razor.cs @@ -9,12 +9,15 @@ namespace LumexUI; -public partial class LumexCheckbox : LumexInputBase, ISlotComponent +public partial class LumexCheckbox : LumexBooleanInputBase, ISlotComponent { /// - /// Gets or sets content to be rendered inside the checkbox. + /// Gets or sets the border radius of the checkbox. /// - [Parameter] public RenderFragment? ChildContent { get; set; } + /// + /// The default is + /// + [Parameter] public Radius Radius { get; set; } = Radius.Medium; /// /// Gets or sets the icon to be used for indicating a checked state of the checkbox. @@ -42,10 +45,6 @@ public partial class LumexCheckbox : LumexInputBase, ISlotComponent /// Initializes a new instance of the . /// @@ -59,7 +58,7 @@ public override async Task SetParametersAsync( ParameterView parameters ) { await base.SetParametersAsync( parameters ); - Color = parameters.TryGetValue( nameof( Color ), out var color ) + Color = parameters.TryGetValue( nameof( Color ), out var color ) ? color : Context?.Owner.Color ?? ThemeColor.Primary; @@ -78,30 +77,10 @@ public override async Task SetParametersAsync( ParameterView parameters ) } /// - protected override void OnParametersSet() - { - _checked = CurrentValue; - _disabled = Disabled || ( Context?.Owner.Disabled ?? false ); - _readonly = ReadOnly || ( Context?.Owner.ReadOnly ?? false ); - } + protected internal override bool GetDisabledState() => + Disabled || ( Context?.Owner.Disabled ?? false ); /// - protected override bool TryParseValueFromString( string? value, out bool result ) - { - throw new NotSupportedException( - $"This component does not parse string inputs. " + - $"Bind to the '{nameof( CurrentValue )}' property, not '{nameof( CurrentValueAsString )}'." ); - } - - internal bool GetDisabledState() => _disabled; - - private Task OnChangeAsync( ChangeEventArgs args ) - { - if( _disabled || _readonly ) - { - return Task.CompletedTask; - } - - return SetCurrentValueAsync( (bool)args.Value! ); - } -} \ No newline at end of file + protected internal override bool GetReadOnlyState() => + ReadOnly || ( Context?.Owner.ReadOnly ?? false ); +} diff --git a/src/LumexUI/Components/Switch/LumexSwitch.razor b/src/LumexUI/Components/Switch/LumexSwitch.razor new file mode 100644 index 00000000..5db1fa94 --- /dev/null +++ b/src/LumexUI/Components/Switch/LumexSwitch.razor @@ -0,0 +1,47 @@ +@namespace LumexUI +@inherits LumexBooleanInputBase + + + + + + + + @if( !string.IsNullOrEmpty( StartIcon ) ) + { + + } + + + @if( !string.IsNullOrEmpty( ThumbIcon ) ) + { + + } + + + @if( !string.IsNullOrEmpty( EndIcon ) ) + { + + } + + + @if( ChildContent is not null ) + { + + @ChildContent + + } + diff --git a/src/LumexUI/Components/Switch/LumexSwitch.razor.cs b/src/LumexUI/Components/Switch/LumexSwitch.razor.cs new file mode 100644 index 00000000..38e6b6a7 --- /dev/null +++ b/src/LumexUI/Components/Switch/LumexSwitch.razor.cs @@ -0,0 +1,59 @@ +// Copyright (c) LumexUI 2024 +// LumexUI licenses this file to you under the MIT license +// See the license here https://github.com/LumexUI/lumexui/blob/main/LICENSE + +using LumexUI.Common; +using LumexUI.Styles; + +using Microsoft.AspNetCore.Components; + +namespace LumexUI; + +public partial class LumexSwitch : LumexBooleanInputBase, ISlotComponent +{ + /// + /// Gets or sets the icon to be used for indicating a checked state of the switch. + /// + [Parameter] public string? ThumbIcon { get; set; } + + /// + /// Gets or sets the icon to be rendered before the switch. + /// + [Parameter] public string? StartIcon { get; set; } + + /// + /// Gets or sets the icon to be rendered after the switch. + /// + [Parameter] public string? EndIcon { get; set; } + + /// + /// Gets or sets the CSS class names for the switch slots. + /// + [Parameter] public SwitchSlots? Classes { get; set; } + + private protected override string? RootClass => + TwMerge.Merge( Switch.GetStyles( this ) ); + + private string? WrapperClass => + TwMerge.Merge( Switch.GetWrapperStyles( this ) ); + + private string? ThumbClass => + TwMerge.Merge( Switch.GetThumbStyles( this ) ); + + private string? ThumbIconClass => + TwMerge.Merge( Switch.GetThumbIconStyles( this ) ); + + private string? StartIconClass => + TwMerge.Merge( Switch.GetStartIconStyles( this ) ); + + private string? EndIconClass => + TwMerge.Merge( Switch.GetEndIconStyles( this ) ); + + private string? LabelClass => + TwMerge.Merge( Switch.GetLabelStyles( this ) ); + + public LumexSwitch() + { + Color = ThemeColor.Primary; + } +} diff --git a/src/LumexUI/Components/Switch/SwitchSlots.cs b/src/LumexUI/Components/Switch/SwitchSlots.cs new file mode 100644 index 00000000..44a3ea21 --- /dev/null +++ b/src/LumexUI/Components/Switch/SwitchSlots.cs @@ -0,0 +1,17 @@ +using System.Diagnostics.CodeAnalysis; + +using LumexUI.Common; + +namespace LumexUI; + +[ExcludeFromCodeCoverage] +public class SwitchSlots : ISlot +{ + public string? Root { get; set; } + public string? Wrapper { get; set; } + public string? Thumb { get; set; } + public string? StartIcon { get; set; } + public string? EndIcon { get; set; } + public string? ThumbIcon { get; set; } + public string? Label { get; set; } +} diff --git a/src/LumexUI/Styles/Switch.cs b/src/LumexUI/Styles/Switch.cs new file mode 100644 index 00000000..06f6d0d6 --- /dev/null +++ b/src/LumexUI/Styles/Switch.cs @@ -0,0 +1,206 @@ +// Copyright (c) LumexUI 2024 +// LumexUI licenses this file to you under the MIT license +// See the license here https://github.com/LumexUI/lumexui/blob/main/LICENSE + +using System.Diagnostics.CodeAnalysis; + +using LumexUI.Common; +using LumexUI.Utilities; + +namespace LumexUI.Styles; + +[ExcludeFromCodeCoverage] +internal readonly record struct Switch +{ + private readonly static string _base = ElementClass.Empty() + .Add( "group" ) + .Add( "relative" ) + .Add( "inline-flex" ) + .Add( "items-center" ) + .Add( "justify-start" ) + .Add( "outline-none" ) + .Add( "cursor-pointer" ) + .Add( "touch-none" ) + .ToString(); + + private readonly static string _wrapper = ElementClass.Empty() + .Add( "px-1" ) + .Add( "mr-2" ) + .Add( "relative" ) + .Add( "inline-flex" ) + .Add( "items-center" ) + .Add( "justify-start" ) + .Add( "flex-shrink-0" ) + .Add( "overflow-hidden" ) + .Add( "bg-default-200" ) + .Add( "rounded-full" ) + //transition + .Add( "transition-background" ) + // focus ring + .Add( Utils.GroupFocusVisible ) + .ToString(); + + private readonly static string _thumb = ElementClass.Empty() + .Add( "z-10" ) + .Add( "flex" ) + .Add( "items-center" ) + .Add( "justify-center" ) + .Add( "bg-white" ) + .Add( "shadow-small" ) + .Add( "rounded-full" ) + .Add( "origin-right" ) + // transition + .Add( "transition-all" ) + .ToString(); + + private readonly static string _thumbIcon = ElementClass.Empty() + .Add( "p-0.5" ) + .Add( "text-black" ) + .ToString(); + + private readonly static string _startIcon = ElementClass.Empty() + .Add( "z-0" ) + .Add( "p-0.5" ) + .Add( "absolute" ) + .Add( "left-1.5" ) + .Add( "text-current" ) + // transition + .Add( "opacity-0" ) + .Add( "scale-50" ) + .Add( "transition-[transform,opacity]" ) + .Add( "group-data-[checked]:scale-100" ) + .Add( "group-data-[checked]:opacity-100" ) + .ToString(); + + private readonly static string _endIcon = ElementClass.Empty() + .Add( "z-0" ) + .Add( "p-0.5" ) + .Add( "absolute" ) + .Add( "right-1.5" ) + .Add( "text-default-600" ) + // transition + .Add( "opacity-100" ) + .Add( "transition-[transform,opacity]" ) + .Add( "group-data-[checked]:translate-x-3" ) + .Add( "group-data-[checked]:opacity-0" ) + .ToString(); + + private readonly static string _label = ElementClass.Empty() + .Add( "relative" ) + .Add( "text-foreground" ) + .Add( "select-none" ) + .ToString(); + + private readonly static string _disabled = ElementClass.Empty() + .Add( "opacity-disabled" ) + .Add( "pointer-events-none" ) + .ToString(); + + private static ElementClass GetColorStyles( ThemeColor color ) + { + return ElementClass.Empty() + .Add( "group-data-[checked]:bg-default-400 group-data-[checked]:text-default-foreground", when: color is ThemeColor.Default ) + .Add( "group-data-[checked]:bg-primary group-data-[checked]:text-primary-foreground", when: color is ThemeColor.Primary ) + .Add( "group-data-[checked]:bg-secodary group-data-[checked]:text-secondary-foreground", when: color is ThemeColor.Secondary ) + .Add( "group-data-[checked]:bg-success group-data-[checked]:text-success-foreground", when: color is ThemeColor.Success ) + .Add( "group-data-[checked]:bg-warning group-data-[checked]:text-warning-foreground", when: color is ThemeColor.Warning ) + .Add( "group-data-[checked]:bg-danger group-data-[checked]:text-danger-foreground", when: color is ThemeColor.Danger ) + .Add( "group-data-[checked]:bg-info group-data-[checked]:text-info-foreground", when: color is ThemeColor.Info ); + } + + private static ElementClass GetSizeStyles( Size size, string slot ) + { + if( slot is "wrapper" ) + { + return ElementClass.Empty() + .Add( "w-10 h-5", when: size is Size.Small ) + .Add( "w-12 h-6", when: size is Size.Medium ) + .Add( "w-14 h-7", when: size is Size.Large ); + } + else if( slot is "thumb" ) + { + return ElementClass.Empty() + .Add( "w-3 h-3 text-tiny group-data-[checked]:ml-5 group-active:w-4 group-data-[checked]:group-active:ml-3", when: size is Size.Small ) + .Add( "w-4 h-4 text-small group-data-[checked]:ml-6 group-active:w-5 group-data-[checked]:group-active:ml-4", when: size is Size.Medium ) + .Add( "w-5 h-5 text-medium group-data-[checked]:ml-7 group-active:w-6 group-data-[checked]:group-active:ml-5", when: size is Size.Large ); + } + else if( slot is "startIcon" or "endIcon" ) + { + return ElementClass.Empty() + .Add( "text-tiny", when: size is Size.Small ) + .Add( "text-small", when: size is Size.Medium ) + .Add( "text-medium", when: size is Size.Large ); + } + else // part is "label" + { + return ElementClass.Empty() + .Add( "text-small", when: size is Size.Small ) + .Add( "text-medium", when: size is Size.Medium ) + .Add( "text-large", when: size is Size.Large ); + } + } + + public static string GetStyles( LumexSwitch @switch ) + { + return ElementClass.Empty() + .Add( _base ) + .Add( _disabled, when: @switch.Disabled ) + .Add( @switch.Classes?.Root ) + .Add( @switch.Class ) + .ToString(); + } + + public static string GetWrapperStyles( LumexSwitch @switch ) + { + return ElementClass.Empty() + .Add( _wrapper ) + .Add( GetColorStyles( @switch.Color ) ) + .Add( GetSizeStyles( @switch.Size, slot: "wrapper" ) ) + .Add( @switch.Classes?.Wrapper ) + .ToString(); + } + + public static string GetThumbStyles( LumexSwitch @switch ) + { + return ElementClass.Empty() + .Add( _thumb ) + .Add( GetSizeStyles( @switch.Size, slot: "thumb" ) ) + .Add( @switch.Classes?.Thumb ) + .ToString(); + } + + public static string GetThumbIconStyles( LumexSwitch @switch ) + { + return ElementClass.Empty() + .Add( _thumbIcon ) + .Add( @switch.Classes?.ThumbIcon ) + .ToString(); + } + + public static string GetStartIconStyles( LumexSwitch @switch ) + { + return ElementClass.Empty() + .Add( _startIcon ) + .Add( GetSizeStyles( @switch.Size, slot: "startIcon" ) ) + .Add( @switch.Classes?.StartIcon ) + .ToString(); + } + + public static string GetEndIconStyles( LumexSwitch @switch ) + { + return ElementClass.Empty() + .Add( _endIcon ) + .Add( GetSizeStyles( @switch.Size, slot: "endIcon" ) ) + .Add( @switch.Classes?.EndIcon ) + .ToString(); + } + + public static string GetLabelStyles( LumexSwitch @switch ) + { + return ElementClass.Empty() + .Add( _label ) + .Add( GetSizeStyles( @switch.Size, slot: "label" ) ) + .Add( @switch.Classes?.Label ) + .ToString(); + } +} diff --git a/tests/LumexUI.Tests/Components/Switch/SwitchTests.cs b/tests/LumexUI.Tests/Components/Switch/SwitchTests.cs new file mode 100644 index 00000000..e6e72319 --- /dev/null +++ b/tests/LumexUI.Tests/Components/Switch/SwitchTests.cs @@ -0,0 +1,135 @@ +// Copyright (c) LumexUI 2024 +// LumexUI licenses this file to you under the MIT license +// See the license here https://github.com/LumexUI/lumexui/blob/main/LICENSE + +using LumexUI.Common; + +using Microsoft.Extensions.DependencyInjection; + +using TailwindMerge; + +namespace LumexUI.Tests.Components; + +public class SwitchTests : TestContext +{ + public SwitchTests() + { + Services.AddSingleton(); + } + + [Fact] + public void Switch_ShouldRenderCorrectly() + { + var action = () => RenderComponent( p => p + .Add( p => p.ValueExpression, () => true ) + ); + + action.Should().NotThrow(); + } + + [Fact] + public void Switch_WithChildContent_ShouldRenderCorrectly() + { + var cut = RenderComponent( p => p + .Add( p => p.ValueExpression, () => true ) + .AddChildContent( "switch" ) + ); + + cut.Markup.Should().Contain( "switch" ); + } + + [Fact] + public void Switch_WithThumbIcon_ShouldRenderCustomIcon() + { + var cut = RenderComponent( p => p + .Add( p => p.ValueExpression, () => true ) + .Add( p => p.ThumbIcon, Icons.Rounded.Headphones ) + ); + + cut.FindComponent().Should().NotBeNull(); + } + + [Fact] + public void Switch_WithStartIcon_ShouldRenderCustomIcon() + { + var cut = RenderComponent( p => p + .Add( p => p.ValueExpression, () => true ) + .Add( p => p.StartIcon, Icons.Rounded.Headphones ) + ); + + cut.FindComponent().Should().NotBeNull(); + } + + [Fact] + public void Switch_WithEndIcon_ShouldRenderCustomIcon() + { + var cut = RenderComponent( p => p + .Add( p => p.ValueExpression, () => true ) + .Add( p => p.EndIcon, Icons.Rounded.Headphones ) + ); + + cut.FindComponent().Should().NotBeNull(); + } + + [Fact] + public void Switch_WithStartAndEndIcon_ShouldRenderCustomIcons() + { + var cut = RenderComponent( p => p + .Add( p => p.ValueExpression, () => true ) + .Add( p => p.StartIcon, Icons.Rounded.HeadphonesBattery ) + .Add( p => p.EndIcon, Icons.Rounded.Headphones ) + ); + + cut.FindComponents().Should().NotBeNull(); + cut.FindComponents().Should().HaveCount( 2 ); + } + + [Fact] + public void Switch_OnChange_ShouldChangeValue() + { + var cut = RenderComponent( p => p + .Add( p => p.Value, false ) + .Add( p => p.ValueExpression, () => true ) + ); + + cut.Instance.Value.Should().BeFalse(); + + var @switch = cut.Find( "input" ); + @switch.Change( true ); + + cut.Instance.Value.Should().BeTrue(); + } + + [Theory] + [InlineData( true, false )] + [InlineData( false, true )] + public void Switch_DisabledOrReadOnly_ShouldNotTriggerChange( bool disabled, bool readOnly ) + { + var cut = RenderComponent( p => p + .Add( p => p.Value, true ) + .Add( p => p.ValueExpression, () => true ) + .Add( p => p.Disabled, disabled ) + .Add( p => p.ReadOnly, readOnly ) + ); + + cut.Instance.Value.Should().BeTrue(); + + var @switch = cut.Find( "input" ); + @switch.Change( false ); + + cut.Instance.Value.Should().BeTrue(); + } + + [Fact] + public async Task Switch_SetCurrentValueAsString_ShouldThrowNotSupported() + { + var cut = RenderComponent( p => p + .Add( p => p.Value, false ) + .Add( p => p.ValueExpression, () => true ) + ); + + var action = async () => await cut.Instance.SetCurrentValueAsStringAsync( "true" ); + + await action.Should().ThrowAsync(); + } +} \ No newline at end of file