diff --git a/.editorconfig b/.editorconfig index ee1d64b1..d383a8f9 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,7 +2,7 @@ root = true # C# files -[*.cs] +[*.{cs,cshtml,razor}] #### Core EditorConfig Options #### diff --git a/.gitignore b/.gitignore index 8e3ee2a9..1a57f75d 100644 --- a/.gitignore +++ b/.gitignore @@ -402,3 +402,9 @@ FodyWeavers.xsd /docs/LumexUI.Docs/**/*/css /docs/LumexUI.Docs/**/*.exe + +# .idea Folder for Rider (IntelliJ IDEA) +.idea/* + +# MacOS DS_Store files +**/.DS_Store diff --git a/LumexUI.sln b/LumexUI.sln index 35184c4c..6e928b5a 100644 --- a/LumexUI.sln +++ b/LumexUI.sln @@ -8,6 +8,8 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{F98196F8-3B41-461E-A47A-CC1B80E34C68}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig + .gitignore = .gitignore + README.md = README.md EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LumexUI.Utilities", "src\LumexUI.Utilities\LumexUI.Utilities.csproj", "{92A1C629-AB3E-4264-B03D-407E09162902}" diff --git a/README.md b/README.md index f41436e3..693b4c0f 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,15 @@ 🚀 A versatile Blazor UI library built using Tailwind CSS.

+## Introduction + +LumexUI is an open-source project that offers a diverse collection of Blazor UI components, +all fully built with Tailwind CSS for streamlined and modern web development. + +These components are designed to be not only aesthetically pleasing, but also highly customizable, +allowing you to tailor them to meet your specific needs. The library is optimized for performance, +ensuring that your applications remain fast and responsive. + ## Documentation For full documentation, visit [lumexui.org](https://lumexui.org). diff --git a/docs/LumexUI.Docs.Client/Common/Navigation/NavigationStore.cs b/docs/LumexUI.Docs.Client/Common/Navigation/NavigationStore.cs index 5cd79067..22047e54 100644 --- a/docs/LumexUI.Docs.Client/Common/Navigation/NavigationStore.cs +++ b/docs/LumexUI.Docs.Client/Common/Navigation/NavigationStore.cs @@ -36,6 +36,7 @@ public class NavigationStore .Add( new( nameof( LumexNavbar ) ) ) .Add( new( nameof( LumexNumbox ) ) ) .Add( new( nameof( LumexPopover ) ) ) + .Add( new( nameof( LumexRadioGroup), ComponentStatus.New ) ) .Add( new( nameof( LumexSelect ), ComponentStatus.New ) ) .Add( new( nameof( LumexSwitch ) ) ) .Add( new( nameof( LumexTextbox ) ) ); diff --git a/docs/LumexUI.Docs.Client/Components/DocsFooter.razor b/docs/LumexUI.Docs.Client/Components/DocsFooter.razor index 7ec03ee1..3610437b 100644 --- a/docs/LumexUI.Docs.Client/Components/DocsFooter.razor +++ b/docs/LumexUI.Docs.Client/Components/DocsFooter.razor @@ -1,7 +1,27 @@ @namespace LumexUI.Docs.Client.Components
-
-

.

+
+

+ © @(DateTimeOffset.UtcNow.ToString("yyyy")) LumexUI. All rights reserved. +

+ +
+ + + GitHub + + + + + Discord + +
diff --git a/docs/LumexUI.Docs.Client/Components/DocsSlotsSection.razor b/docs/LumexUI.Docs.Client/Components/DocsSlotsSection.razor index be05939e..2a66a03e 100644 --- a/docs/LumexUI.Docs.Client/Components/DocsSlotsSection.razor +++ b/docs/LumexUI.Docs.Client/Components/DocsSlotsSection.razor @@ -1,8 +1,8 @@ @namespace LumexUI.Docs.Client.Components - +

- This component suppots named slots (represented as data-* attributes) that + This component supports named slots that allow you to apply custom CSS to specific parts of the component.

    @@ -23,5 +23,6 @@ @code { [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public string Title { get; set; } = "Custom Styles"; [Parameter, EditorRequired] public Slot[] Slots { get; set; } = default!; } diff --git a/docs/LumexUI.Docs.Client/LumexUI.Docs.Client.csproj b/docs/LumexUI.Docs.Client/LumexUI.Docs.Client.csproj index 8c0223d3..9fde0c3f 100644 --- a/docs/LumexUI.Docs.Client/LumexUI.Docs.Client.csproj +++ b/docs/LumexUI.Docs.Client/LumexUI.Docs.Client.csproj @@ -14,8 +14,8 @@ - - + + diff --git a/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/Colors.razor b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/Colors.razor new file mode 100644 index 00000000..84ccc924 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/Colors.razor @@ -0,0 +1,9 @@ + + Default + Primary + Secondary + Success + Warning + Danger + Info + diff --git a/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/CustomStyles.razor b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/CustomStyles.razor new file mode 100644 index 00000000..0a70f982 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/CustomStyles.razor @@ -0,0 +1,69 @@ + + +
    + +
    +
    Chicken Kebab
    +

    $11.99

    +
    +
    +
    + + +
    + +
    +
    Ramen Bowl
    +

    $12.99

    +
    +
    +
    + + +
    + +
    +
    Pizza
    +

    $5.99

    +
    +
    +
    + + +
    + +
    +
    No Food
    +

    No Cost

    +
    +
    +
    +
    + +@code +{ + private string? _chosenFood; + + private RadioGroupSlots _groupSlots = new() + { + Label = "text-neutral-700 font-medium" + }; + + private RadioSlots _radioClasses = new() + { + Root = ElementClass.Empty() + .Add("inline-flex m-0 bg-surface1 hover:bg-surface2 items-center justify-between") + .Add("flex-row-reverse max-w-[300px] rounded-lg gap-4 p-4 border-2 border-default-200") + .Add("data-[selected=true]:border-primary") + .Add("data-[selected=true]:bg-primary-100/25") + .Add("transition-colors") + .ToString() + }; +} diff --git a/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/Description.razor b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/Description.razor new file mode 100644 index 00000000..6811f9a4 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/Description.razor @@ -0,0 +1,16 @@ + + Leonardo DiCaprio + Denzel Washington + Brad Pitt + Joaquin Phoenix + Christian Bale + + +@code +{ + private string? _selectedActor; + + private string CalculatedDescription => $"You chose {_selectedActor ?? "nobody"} to win the Oscar"; +} diff --git a/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/Disabled.razor b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/Disabled.razor new file mode 100644 index 00000000..71140797 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/Disabled.razor @@ -0,0 +1,7 @@ + + Leonardo DiCaprio + Denzel Washington + Brad Pitt + Joaquin Phoenix + Christian Bale + diff --git a/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/Label.razor b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/Label.razor new file mode 100644 index 00000000..a6e85ec1 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/Label.razor @@ -0,0 +1,7 @@ + + Leonardo DiCaprio + Denzel Washington + Brad Pitt + Joaquin Phoenix + Christian Bale + diff --git a/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/OptionDescriptions.razor b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/OptionDescriptions.razor new file mode 100644 index 00000000..048f366a --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/OptionDescriptions.razor @@ -0,0 +1,14 @@ + + + First Class + + + Second Class + + + Third Class + + + Steerage + + diff --git a/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/ReadOnly.razor b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/ReadOnly.razor new file mode 100644 index 00000000..17395999 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/ReadOnly.razor @@ -0,0 +1,7 @@ + + Leonardo DiCaprio + Denzel Washington + Brad Pitt + Joaquin Phoenix + Christian Bale + diff --git a/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/Sizes.razor b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/Sizes.razor new file mode 100644 index 00000000..65f2b1e5 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/Sizes.razor @@ -0,0 +1,5 @@ + + Small + Medium + Large + diff --git a/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/TwoWayDataBinding.razor b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/TwoWayDataBinding.razor new file mode 100644 index 00000000..718d931b --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/TwoWayDataBinding.razor @@ -0,0 +1,37 @@ +
    + + Leonardo DiCaprio + Denzel Washington + Brad Pitt + Joaquin Phoenix + Christian Bale + +

    + Selected: @_valueOne +

    +
    + +
    + + Leonardo DiCaprio + Denzel Washington + Brad Pitt + Joaquin Phoenix + Christian Bale + +

    + Selected: @_valueTwo +

    +
    + +@code { + private string? _valueOne; + private string? _valueTwo; + + private void OnValueChanged(string value) + { + _valueTwo = value; + } +} \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/Usage.razor b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/Usage.razor new file mode 100644 index 00000000..45e6859d --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/Usage.razor @@ -0,0 +1,7 @@ + + Leonardo DiCaprio + Denzel Washington + Brad Pitt + Joaquin Phoenix + Christian Bale + diff --git a/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/_Orientation.razor b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/_Orientation.razor new file mode 100644 index 00000000..d5f79adf --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/_Orientation.razor @@ -0,0 +1,6 @@ + + First Class + Second Class + Third Class + Steerage + diff --git a/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Colors.razor b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Colors.razor new file mode 100644 index 00000000..bb8d3e51 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Colors.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + diff --git a/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/CustomStyles.razor b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/CustomStyles.razor new file mode 100644 index 00000000..c022caef --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/CustomStyles.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + diff --git a/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Description.razor b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Description.razor new file mode 100644 index 00000000..1e1147f8 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Description.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + diff --git a/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Disabled.razor b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Disabled.razor new file mode 100644 index 00000000..2f03fc40 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Disabled.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + diff --git a/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Label.razor b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Label.razor new file mode 100644 index 00000000..85e09ad5 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Label.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + diff --git a/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/OptionDescriptions.razor b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/OptionDescriptions.razor new file mode 100644 index 00000000..5f07310f --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/OptionDescriptions.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + diff --git a/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Orientation.razor b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Orientation.razor new file mode 100644 index 00000000..782cb8a9 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Orientation.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + diff --git a/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/ReadOnly.razor b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/ReadOnly.razor new file mode 100644 index 00000000..1d5bacb6 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/ReadOnly.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + diff --git a/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Sizes.razor b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Sizes.razor new file mode 100644 index 00000000..c80f9989 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Sizes.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + diff --git a/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/TwoWayDataBinding.razor b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/TwoWayDataBinding.razor new file mode 100644 index 00000000..c456c9ec --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/TwoWayDataBinding.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + diff --git a/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Usage.razor b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Usage.razor new file mode 100644 index 00000000..70852b91 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Usage.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + diff --git a/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/RadioGroup.razor b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/RadioGroup.razor new file mode 100644 index 00000000..b78ca8e4 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/RadioGroup.razor @@ -0,0 +1,167 @@ +@page "/docs/components/radio-group" +@layout DocsContentLayout + +@using LumexUI.Docs.Client.Pages.Components.RadioGroup.PreviewCodes + + +

    + The radio group component groups multiple radio buttons, + displaying related choices in a consistent manner. +

    + + + +

    + You can disable a radio group to prevent user interaction. + All radio buttons within the group are faded and do not respond to user clicks. +

    + +
    + + +

    + You can set a radio group to be read-only, allowing the user to + view the selected options without being able to modify them. +

    + +
    + + +

    + You can control how the radio buttons are laid out within the radio group. You can choose to + display the radio buttons vertically (default) or horizontally. +

    + +
    + + +

    + The radio group component can control the size of the radio buttons within it to fit + various layouts and design needs, from small radio groups to larger ones. +

    + +
    + + +

    + Customize the appearance of the radio group by applying + different colors that suit your application's theme and design. +

    + +
    + + +

    + The radio group can include a label to provide context for the + grouped radio buttons, making it clear what the set of options represents. +

    +
    + + +

    + Add a description to the radio group to give users + additional information about the grouped options. +

    + +
    + + +

    + You can also display a description for each radio group option, + guiding users to make the best choice. +

    + +
    + + +

    + The radio group component supports two-way data binding, + allowing you to programmatically control toggled state. + You can achieve this using the @@bind-Value directive, + or the Value and ValueChanged parameters. +

    + +
    +
    + + + +
      +
    • Class: The CSS class names to style the radio group wrapper.
    • +
    • Classes: The CSS class names to style the radio group slots.
    • +
    • RadioClasses: The CSS class names to style the radio group option slots.
    • +
    +
    + + +
      +
    • Class: The CSS class names to style the radio button wrapper.
    • +
    • Classes: The CSS class names to style the radio button slots.
    • +
    +
    + + +
    + + + +@code { + + [CascadingParameter] private DocsContentLayout Layout { get; set; } = default!; + + private readonly Heading[] _headings = + [ + new("Usage", [ + new("Disabled"), + new("Read-Only"), + new("Orientation"), + new("Sizes"), + new("Colors"), + new("Label"), + new("Description"), + new("Option Descriptions"), + new("Two-way Data Binding") + ]), + new("Custom Styles", [ + new("Radio Group"), + new("Radio") + ]), + new("API") + ]; + + private readonly Slot[] _radioGroupSlots = + [ + new(nameof(RadioGroupSlots.Root), "The overall container of the radio group."), + new(nameof(RadioGroupSlots.Wrapper), "The wrapper for the radio buttons within the group."), + new(nameof(RadioGroupSlots.Label), "The label associated with the radio group."), + new(nameof(RadioGroupSlots.Description), "The description of the radio group.") + ]; + + private readonly Slot[] _radioSlots = + [ + new(nameof(RadioSlots.Root), "The main wrapper around the radio option."), + new(nameof(RadioSlots.ControlWrapper), "The wrapper around the visual control (the actual radio button)."), + new(nameof(RadioSlots.Control), "The radio button control (the visual circle)."), + new(nameof(RadioSlots.LabelWrapper), "The wrapper around the label and description."), + new(nameof(RadioSlots.Label), "The label associated with the radio option."), + new(nameof(RadioSlots.Description), "The description of the radio option.") + ]; + + private readonly string[] _apiComponents = + [ + nameof(LumexRadioGroup), + nameof(LumexRadio) + ]; + + protected override void OnInitialized() + { + Layout.Initialize( + title: "Radio Group", + category: "Components", + description: "The radio group allows users to select one option from a set.", + headings: _headings, + linksProps: new ComponentLinksProps("Radio", isServer: false) + ); + } +} diff --git a/docs/LumexUI.Docs.Generator/LumexUI.Docs.Generator.csproj b/docs/LumexUI.Docs.Generator/LumexUI.Docs.Generator.csproj index 78509143..710df55f 100644 --- a/docs/LumexUI.Docs.Generator/LumexUI.Docs.Generator.csproj +++ b/docs/LumexUI.Docs.Generator/LumexUI.Docs.Generator.csproj @@ -8,7 +8,7 @@ - + all diff --git a/docs/LumexUI.Docs/LumexUI.Docs.csproj b/docs/LumexUI.Docs/LumexUI.Docs.csproj index e3bacbf1..807e6ddf 100644 --- a/docs/LumexUI.Docs/LumexUI.Docs.csproj +++ b/docs/LumexUI.Docs/LumexUI.Docs.csproj @@ -15,7 +15,7 @@ - + diff --git a/src/LumexUI/Common/Interfaces/IComponentContext.cs b/src/LumexUI/Common/Interfaces/IComponentContext.cs index cc2080bc..7d834434 100644 --- a/src/LumexUI/Common/Interfaces/IComponentContext.cs +++ b/src/LumexUI/Common/Interfaces/IComponentContext.cs @@ -1,6 +1,6 @@ namespace LumexUI.Common; -internal interface IComponentContext where T : LumexComponentBase +internal interface IComponentContext where T : LumexComponentBase { T Owner { get; } } diff --git a/src/LumexUI/Components/Radio/IRadioGroupValueProvider.cs b/src/LumexUI/Components/Radio/IRadioGroupValueProvider.cs new file mode 100644 index 00000000..08180f78 --- /dev/null +++ b/src/LumexUI/Components/Radio/IRadioGroupValueProvider.cs @@ -0,0 +1,17 @@ +// 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 + +namespace LumexUI; + +/// +/// Interface for providing the value of a radio group. +/// +/// Type of value the radio group represents. +public interface IRadioGroupValueProvider +{ + /// + /// The currently-selected value of the radio group. + /// + public TValue? CurrentValue { get; } +} \ No newline at end of file diff --git a/src/LumexUI/Components/Radio/LumexRadio.razor b/src/LumexUI/Components/Radio/LumexRadio.razor new file mode 100644 index 00000000..fe5a53cc --- /dev/null +++ b/src/LumexUI/Components/Radio/LumexRadio.razor @@ -0,0 +1,42 @@ +@namespace LumexUI +@inherits LumexComponentBase +@typeparam TValue + + + + + + + + + + +
    + @if (ChildContent is not null) + { + + @ChildContent + + } + + @if (Description is not null) + { + + @Description + + } +
    +
    diff --git a/src/LumexUI/Components/Radio/LumexRadio.razor.cs b/src/LumexUI/Components/Radio/LumexRadio.razor.cs new file mode 100644 index 00000000..fb5bc666 --- /dev/null +++ b/src/LumexUI/Components/Radio/LumexRadio.razor.cs @@ -0,0 +1,132 @@ +// 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; + +[CompositionComponent( typeof( LumexRadioGroup<> ) )] +public partial class LumexRadio : LumexComponentBase, ISlotComponent +{ + /// + /// Gets or sets a value indicating whether the input is disabled. + /// + [Parameter] public bool Disabled { get; set; } + + /// + /// Gets or sets a value indicating whether the input is read-only. + /// + [Parameter] public bool ReadOnly { get; set; } + + /// + /// Gets or sets a value indicating whether the input is required. + /// + [Parameter] public bool Required { get; set; } + + /// + /// Gets or sets a value indicating whether the input is invalid. + /// + [Parameter] public bool Invalid { get; set; } + + /// + /// Gets or sets the CSS class names for the checkbox slots. + /// + [Parameter] public RadioSlots? Classes { get; set; } + + /// + /// Gets or sets a color of the radio button. + /// + /// + /// The default is + /// + [Parameter] public ThemeColor Color { get; set; } = ThemeColor.Primary; + + /// + /// Gets or sets the size of the radio button. + /// + /// + /// The default value is + /// + [Parameter] public Size Size { get; set; } = Size.Medium; + + /// + /// Gets or sets the description for this particular option. + /// + [Parameter] public string? Description { get; set; } + + /// + /// Gets or sets the value of this input. + /// + [Parameter] public TValue? Value { get; set; } + + /// + /// Gets or sets content to be rendered inside the input. + /// + [Parameter] public RenderFragment? ChildContent { get; set; } + + [CascadingParameter( Name = "Context" )] internal RadioGroupContext Context { get; set; } = default!; + + private protected override string? RootClass => + TwMerge.Merge( Radio.GetStyles( this ) ); + + private string? ControlWrapperClass => + TwMerge.Merge( Radio.GetControlWrapperStyles( this ) ); + + private string? ControlClass => + TwMerge.Merge( Radio.GetControlStyles( this ) ); + + private string? LabelWrapperClass => + TwMerge.Merge( Radio.GetLabelWrapperStyles( this ) ); + + private string? LabelClass => + TwMerge.Merge( Radio.GetLabelStyles( this ) ); + + private string? DescriptionClass => + TwMerge.Merge( Radio.GetDescriptionStyles( this ) ); + + /// + public override async Task SetParametersAsync( ParameterView parameters ) + { + await base.SetParametersAsync( parameters ); + + Color = parameters.TryGetValue( nameof( Color ), out var color ) + ? color + : Context.Owner.Color; + + Size = parameters.TryGetValue( nameof( Size ), out var size ) + ? size + : Context.Owner.Size; + } + + /// + /// 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 bool GetDisabledState() => Disabled || Context.Owner.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 bool GetReadOnlyState() => ReadOnly || Context.Owner.ReadOnly; + + /// + /// Indicates whether this radio button is selected. + /// Derived classes can override this to determine the input's selected state. + /// + /// true if the of this matches + /// the CurrentValue property in the parent . Otherwise false. + protected internal bool GetSelectedState() => Context.CurrentValue?.Equals( Value ) ?? false; + + /// + protected override void OnInitialized() + { + ContextNullException.ThrowIfNull( Context, nameof( LumexRadio ) ); + } +} \ No newline at end of file diff --git a/src/LumexUI/Components/Radio/LumexRadioGroup.razor b/src/LumexUI/Components/Radio/LumexRadioGroup.razor new file mode 100644 index 00000000..b602a1c3 --- /dev/null +++ b/src/LumexUI/Components/Radio/LumexRadioGroup.razor @@ -0,0 +1,25 @@ +@namespace LumexUI +@inherits LumexInputBase +@typeparam TValue + +@* +Note that we must not set IsFixed=true on the CascadingValue, because the mutations to _context +are what cause the descendant InputRadio components to re-render themselves +*@ + +
    + @if( !string.IsNullOrEmpty( Label ) ) + { + @Label + } + +
    + @ChildContent +
    + + @if( !string.IsNullOrEmpty( Description ) ) + { +
    @Description
    + } +
    +
    \ No newline at end of file diff --git a/src/LumexUI/Components/Radio/LumexRadioGroup.razor.cs b/src/LumexUI/Components/Radio/LumexRadioGroup.razor.cs new file mode 100644 index 00000000..c8fcded5 --- /dev/null +++ b/src/LumexUI/Components/Radio/LumexRadioGroup.razor.cs @@ -0,0 +1,188 @@ +// 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 System.Globalization; + +using LumexUI.Common; +using LumexUI.Styles; + +using Microsoft.AspNetCore.Components; + +namespace LumexUI; + +[CascadingTypeParameter(nameof(TValue))] +public partial class LumexRadioGroup : LumexInputBase, ISlotComponent, IRadioGroupValueProvider +{ + /// + /// Gets or sets content to be rendered inside the radio group. + /// + [Parameter] public RenderFragment? ChildContent { get; set; } + + /// + /// Gets or sets the name of the group. + /// + [Parameter] public string? Name { get; set; } + + /// + /// Gets or sets the label for the radio group. + /// + [Parameter] public string? Label { get; set; } + + /// + /// Gets or sets the description for the radio group. + /// + [Parameter] public string? Description { get; set; } + + /// + /// Gets or sets the orientation of the radio group. + /// + /// + /// The default value is + /// + [Parameter] public Orientation Orientation { get; set; } = Orientation.Vertical; + + /// + /// Gets or sets the CSS class names for the radio group slots. + /// + [Parameter] public RadioGroupSlots? Classes { get; set; } + + /// + /// Gets or sets the CSS class names for the radio button slots. + /// + [Parameter] public RadioSlots? RadioClasses { get; set; } + + /// + TValue? IRadioGroupValueProvider.CurrentValue => Value; + + private protected override string? RootClass => + TwMerge.Merge( RadioGroup.GetStyles( this ) ); + + private string? LabelClass => + TwMerge.Merge( RadioGroup.GetLabelStyles( this ) ); + + private string? WrapperClass => + TwMerge.Merge( RadioGroup.GetWrapperStyles( this ) ); + + private string? DescriptionClass => + TwMerge.Merge( RadioGroup.GetDescriptionStyles( this ) ); + + private readonly string _defaultGroupName = Guid.NewGuid().ToString("N"); + + private RadioGroupContext? _context; + + /// + public override async Task SetParametersAsync( ParameterView parameters ) + { + Color = parameters.TryGetValue( nameof( Color ), out var color ) + ? color + : ThemeColor.Primary; + + Size = parameters.TryGetValue( nameof( Size ), out var size ) + ? size + : Size.Medium; + + // On the first render, we can instantiate the InputRadioContext + if (_context is null) + { + var changeEventCallback = EventCallback.Factory.Create( this, OnValueChangeAsync ); + _context = new RadioGroupContext(this, changeEventCallback); + } + + await base.SetParametersAsync( parameters ); + } + + /// + protected override void OnParametersSet() + { + // Prefer the explicitly-specified group name over anything else. + // Otherwise, just use a GUID to disambiguate this group's radio inputs from any others on the page. + _context!.GroupName = !string.IsNullOrEmpty(Name) ? Name : _defaultGroupName; + + base.OnParametersSet(); + } + + /// + protected override ValueTask SetValidationMessageAsync() + { + return ValueTask.CompletedTask; + } + + /// + protected override bool TryParseValueFromString( string? value, [MaybeNullWhen(false)] out TValue result ) + { + try + { + // We special-case bool values because BindConverter reserves bool conversion for conditional attributes. + if (typeof(TValue) == typeof(bool)) + { + if (TryConvertToBool(value, out result)) + { + return true; + } + } + else if (typeof(TValue) == typeof(bool?)) + { + if (TryConvertToNullableBool(value, out result)) + { + return true; + } + } + else if (BindConverter.TryConvertTo(value, CultureInfo.CurrentCulture, out var parsedValue)) + { + result = parsedValue; + return true; + } + + result = default; + + return false; + } + catch (InvalidOperationException ex) + { + throw new InvalidOperationException($"{Label} does not support the type '{typeof(TValue)}'.", ex); + } + } + + /// + /// 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 async Task OnValueChangeAsync( ChangeEventArgs args ) + { + if( Disabled || ReadOnly ) + { + return; + } + + await SetCurrentValueAsStringAsync( args.Value?.ToString() ); + } + + private static bool TryConvertToBool(string? value, out TValue result) + { + if (bool.TryParse(value, out var @bool)) + { + result = (TValue)(object)@bool; + return true; + } + + result = default!; + return false; + } + + private static bool TryConvertToNullableBool(string? value, out TValue result) + { + // This is unlikely to be true because LumexInputBase.SetCurrentValueAsStringAsync + // should have already handled this case. + if (string.IsNullOrEmpty(value)) + { + result = default!; + return true; + } + + return TryConvertToBool(value, out result); + } +} diff --git a/src/LumexUI/Components/Radio/RadioGroupContext.cs b/src/LumexUI/Components/Radio/RadioGroupContext.cs new file mode 100644 index 00000000..98292fb2 --- /dev/null +++ b/src/LumexUI/Components/Radio/RadioGroupContext.cs @@ -0,0 +1,43 @@ +using LumexUI.Common; + +using Microsoft.AspNetCore.Components; + +namespace LumexUI; + +[CascadingTypeParameter(nameof(TValue))] +internal sealed class RadioGroupContext( LumexRadioGroup owner, EventCallback changeEventCallback ) : IComponentContext> +{ + /// + /// The owner of the context. + /// + /// + /// An instance of will automatically generate this context object and assign itself. + /// + public LumexRadioGroup Owner { get; } = owner; + + /// + /// The callback to be invoked when the value of the radio group changes. + /// + public EventCallback ChangeEventCallback { get; } = changeEventCallback; + + /// + /// The name of the radio group. This is used to group radios together and gets applied to the name attribute of the radio input. + /// + /// + /// If this is not provided explicitly, the name will be generated automatically by the . + /// + public string? GroupName { get; set; } + + /// + /// The currently-selected value of the radio group. + /// + /// + /// It can be set in the parameter, and it will also be updated as the user interacts with the radios. + /// + public TValue? CurrentValue => _valueProvider.CurrentValue; + + /// + /// The provider for the value of the radio group. In this instance, it's . + /// + private readonly IRadioGroupValueProvider _valueProvider = owner; +} \ No newline at end of file diff --git a/src/LumexUI/Components/Radio/RadioGroupSlots.cs b/src/LumexUI/Components/Radio/RadioGroupSlots.cs new file mode 100644 index 00000000..d79be988 --- /dev/null +++ b/src/LumexUI/Components/Radio/RadioGroupSlots.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; + +using LumexUI.Common; + +namespace LumexUI; + +/// +/// Style slots for +/// +[ExcludeFromCodeCoverage] +public class RadioGroupSlots : ISlot +{ + /// + /// Radio group root wrapper, it wraps the label and the wrapper. + /// + public string? Root { get; set; } + + /// + /// Radio group wrapper, it wraps all Radios. + /// + public string? Wrapper { get; set; } + + /// + /// Radio group label, it is placed before the wrapper. + /// + public string? Label { get; set; } + + /// + /// Description slot for the radio group. + /// + public string? Description { get; set; } +} diff --git a/src/LumexUI/Components/Radio/RadioSlots.cs b/src/LumexUI/Components/Radio/RadioSlots.cs new file mode 100644 index 00000000..558f1fc3 --- /dev/null +++ b/src/LumexUI/Components/Radio/RadioSlots.cs @@ -0,0 +1,42 @@ +using System.Diagnostics.CodeAnalysis; + +using LumexUI.Common; + +namespace LumexUI; + +/// +/// Style slots for +/// +[ExcludeFromCodeCoverage] +public class RadioSlots : ISlot +{ + /// + /// Radio root wrapper, it wraps all elements. + /// + public string? Root { get; set; } + + /// + /// Radio wrapper, it wraps the control element. + /// + public string? ControlWrapper { get; set; } + + /// + /// Label and description wrapper. + /// + public string? LabelWrapper { get; set; } + + /// + /// Label slot for the radio. + /// + public string? Label { get; set; } + + /// + /// Control element, it is the circle element. + /// + public string? Control { get; set; } + + /// + /// Description slot for the radio. + /// + public string? Description { get; set; } +} diff --git a/src/LumexUI/Icons/Brands.cs b/src/LumexUI/Icons/Brands.cs index d9d81fff..a908e4dd 100644 --- a/src/LumexUI/Icons/Brands.cs +++ b/src/LumexUI/Icons/Brands.cs @@ -11,6 +11,7 @@ public partial class Brands { public const string Blazor = ""; public const string GitHub = ""; + public const string Discord = ""; } } #pragma warning restore CS1591 diff --git a/src/LumexUI/Infrastructure/InternalNavLink.cs b/src/LumexUI/Infrastructure/InternalNavLink.cs index dbc4b53c..97c7cf06 100644 --- a/src/LumexUI/Infrastructure/InternalNavLink.cs +++ b/src/LumexUI/Infrastructure/InternalNavLink.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using LumexUI.Utilities; @@ -44,6 +45,7 @@ protected override void BuildRenderTree( RenderTreeBuilder builder ) builder.CloseElement(); } + [ExcludeFromCodeCoverage] [UnsafeAccessor( UnsafeAccessorKind.Field, Name = "_isActive" )] private static extern ref bool GetActiveState( NavLink navLink ); } diff --git a/src/LumexUI/LumexUI.csproj b/src/LumexUI/LumexUI.csproj index e4277dd0..5129a7cc 100644 --- a/src/LumexUI/LumexUI.csproj +++ b/src/LumexUI/LumexUI.csproj @@ -46,7 +46,7 @@
    - + diff --git a/src/LumexUI/Scripts/Plugin/transitions.js b/src/LumexUI/Scripts/Plugin/transitions.js index 54601861..22639223 100644 --- a/src/LumexUI/Scripts/Plugin/transitions.js +++ b/src/LumexUI/Scripts/Plugin/transitions.js @@ -29,4 +29,9 @@ export default { 'transition-timing-function': defaultTransitionFunction, 'transition-duration': DEFAULT_TRANSITION_DURATION, }, + '.transition-transform-opacity': { + 'transition-property': 'transform, opacity', + 'transition-timing-function': defaultTransitionFunction, + 'transition-duration': DEFAULT_TRANSITION_DURATION + } }; \ No newline at end of file diff --git a/src/LumexUI/Styles/Radio.cs b/src/LumexUI/Styles/Radio.cs new file mode 100644 index 00000000..241819ec --- /dev/null +++ b/src/LumexUI/Styles/Radio.cs @@ -0,0 +1,301 @@ +// 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; + +internal enum SizeSlots +{ + Wrapper, + Control, + LabelWrapper, + Label, + Description +} + +internal enum ColorSlots +{ + Control, + Wrapper +} + +[ExcludeFromCodeCoverage] +internal static class Radio +{ + private static readonly string _base = ElementClass.Empty() + .Add( "group" ) + .Add( "relative" ) + .Add( "max-w-fit" ) + .Add( "inline-flex" ) + .Add( "items-center" ) + .Add( "justify-start" ) + .Add( "cursor-pointer" ) + .Add( "p-2" ) + .Add( "-m-2" ) + .Add( "select-none" ) + .ToString(); + + private static readonly string _wrapper = ElementClass.Empty() + .Add( "relative" ) + .Add( "inline-flex" ) + .Add( "items-center" ) + .Add( "justify-center" ) + .Add( "shrink-0" ) + .Add( "overflow-hidden" ) + .Add( "border-2" ) + .Add( "border-default" ) + .Add( "rounded-full" ) + .Add( "group-hover:bg-default-100" ) + .Add( "group-active:scale-95" ) + // transition + .Add( "transition-transform-colors" ) + .Add( Utils.ReduceMotion ) + // focus ring + .Add( Utils.GroupFocusVisible ) + .ToString(); + + private static readonly string _control = ElementClass.Empty() + .Add( "z-10" ) + .Add( "w-2" ) + .Add( "h-2" ) + .Add( "opacity-0" ) + .Add( "scale-0" ) + .Add( "origin-center" ) + .Add( "rounded-full" ) + .Add( "group-data-[selected=true]:opacity-100" ) + .Add( "group-data-[selected=true]:scale-100" ) + // transition + .Add( "transition-transform-opacity" ) + .Add( Utils.ReduceMotion ) + .ToString(); + + private static readonly string _labelWrapper = ElementClass.Empty() + .Add( "flex" ) + .Add( "flex-col" ) + .ToString(); + + private static readonly string _label = ElementClass.Empty() + .Add( "group" ) + .Add( "text-foreground" ) + .Add( "select-none" ) + // transition + .Add( "transition-colors-opacity" ) + .Add( Utils.ReduceMotion ) + .ToString(); + + private static readonly string _description = ElementClass.Empty() + .Add( "relative" ) + .Add( "text-foreground-400" ) + // transition + .Add( "transition-colors" ) + .Add( Utils.ReduceMotion ) + .ToString(); + + private static readonly string _disabled = ElementClass.Empty() + .Add( "opacity-disabled" ) + .Add( "pointer-events-none" ) + .ToString(); + + private static ElementClass GetColorStyles( ThemeColor color, ColorSlots slot ) + { + switch( slot ) + { + case ColorSlots.Control: + return ElementClass.Empty() + .Add( "bg-default-500 text-default-foreground", when: color is ThemeColor.Default ) + .Add( "bg-primary text-primary-foreground", when: color is ThemeColor.Primary ) + .Add( "bg-secondary text-secondary-foreground", when: color is ThemeColor.Secondary ) + .Add( "bg-success text-success-foreground", when: color is ThemeColor.Success ) + .Add( "bg-warning text-warning-foreground", when: color is ThemeColor.Warning ) + .Add( "bg-danger text-danger-foreground", when: color is ThemeColor.Danger ) + .Add( "bg-info text-info-foreground", when: color is ThemeColor.Info ); + case ColorSlots.Wrapper: + return ElementClass.Empty() + .Add( "group-data-[selected=true]:border-default-500", when: color is ThemeColor.Default ) + .Add( "group-data-[selected=true]:border-primary", when: color is ThemeColor.Primary ) + .Add( "group-data-[selected=true]:border-secondary", when: color is ThemeColor.Secondary ) + .Add( "group-data-[selected=true]:border-success", when: color is ThemeColor.Success ) + .Add( "group-data-[selected=true]:border-warning", when: color is ThemeColor.Warning ) + .Add( "group-data-[selected=true]:border-danger", when: color is ThemeColor.Danger ) + .Add( "group-data-[selected=true]:border-info", when: color is ThemeColor.Info ); + default: + throw new ArgumentOutOfRangeException( nameof( slot ), slot, "Unsupported slot" ); + } + } + + private static ElementClass GetSizeStyles( Size size, SizeSlots slot ) + { + switch( slot ) + { + case SizeSlots.Wrapper: + return ElementClass.Empty() + .Add( "w-4 h-4", when: size is Size.Small ) + .Add( "w-5 h-5", when: size is Size.Medium ) + .Add( "w-6 h-6", when: size is Size.Large ); + case SizeSlots.Control: + return ElementClass.Empty() + .Add( "w-1.5 h-1.5", when: size is Size.Small ) + .Add( "w-2 h-2", when: size is Size.Medium ) + .Add( "w-2.5 h-2.5", when: size is Size.Large ); + case SizeSlots.LabelWrapper: + return ElementClass.Empty() + .Add( "ml-1", when: size is Size.Small ) + .Add( "ms-2", when: size is Size.Medium ) + .Add( "ms-2", when: size is Size.Large ); + case SizeSlots.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 ); + case SizeSlots.Description: + 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 ); + default: + throw new ArgumentOutOfRangeException( nameof( slot ), slot, "Unsupported slot" ); + } + } + + public static string GetStyles( LumexRadio radio ) + { + var radioGroup = radio.Context.Owner; + + return ElementClass.Empty() + .Add( _base ) + .Add( _disabled, when: radio.GetDisabledState() ) + .Add( radioGroup.RadioClasses?.Root ) + .Add( radio.Classes?.Root ) + .Add( radio.Class ) + .ToString(); + } + + public static string GetControlWrapperStyles( LumexRadio radio ) + { + var radioGroup = radio.Context.Owner; + + return ElementClass.Empty() + .Add( _wrapper ) + .Add( GetColorStyles( radio.Color, slot: ColorSlots.Wrapper ) ) + .Add( GetSizeStyles( radio.Size, slot: SizeSlots.Wrapper ) ) + .Add( radioGroup.RadioClasses?.ControlWrapper ) + .Add( radio.Classes?.ControlWrapper ) + .ToString(); + } + + public static string GetControlStyles( LumexRadio radio ) + { + var radioGroup = radio.Context.Owner; + + return ElementClass.Empty() + .Add( _control ) + .Add( GetColorStyles( radio.Color, slot: ColorSlots.Control ) ) + .Add( GetSizeStyles( radio.Size, slot: SizeSlots.Control ) ) + .Add( radioGroup.RadioClasses?.Control ) + .Add( radio.Classes?.Control ) + .ToString(); + } + + public static string GetLabelWrapperStyles( LumexRadio radio ) + { + var radioGroup = radio.Context.Owner; + + return ElementClass.Empty() + .Add( _labelWrapper ) + .Add( GetSizeStyles( radio.Size, slot: SizeSlots.LabelWrapper ) ) + .Add( radioGroup.RadioClasses?.LabelWrapper ) + .Add( radio.Classes?.LabelWrapper ) + .ToString(); + } + + public static string GetLabelStyles( LumexRadio radio ) + { + var radioGroup = radio.Context.Owner; + + return ElementClass.Empty() + .Add( _label ) + .Add( GetSizeStyles( radio.Size, slot: SizeSlots.Label ) ) + .Add( radioGroup.RadioClasses?.Label ) + .Add( radio.Classes?.Label ) + .ToString(); + } + + public static string GetDescriptionStyles( LumexRadio radio ) + { + var radioGroup = radio.Context.Owner; + + return ElementClass.Empty() + .Add( _description ) + .Add( GetSizeStyles( radio.Size, slot: SizeSlots.Description ) ) + .Add( radioGroup.RadioClasses?.Description ) + .Add( radio.Classes?.Description ) + .ToString(); + } +} + +[ExcludeFromCodeCoverage] +internal static class RadioGroup +{ + private static readonly string _base = ElementClass.Empty() + .Add( "relative" ) + .Add( "flex" ) + .Add( "flex-col" ) + .Add( "gap-2" ) + .ToString(); + + private static readonly string _label = ElementClass.Empty() + .Add( "text-medium" ) + .Add( "text-foreground-500" ) + .ToString(); + + private static readonly string _wrapper = ElementClass.Empty() + .Add( "flex" ) + .Add( "flex-col" ) + .Add( "flex-wrap" ) + .Add( "gap-2" ) + .Add( "data-[orientation=horizontal]:flex-row" ) + .ToString(); + + private static readonly string _description = ElementClass.Empty() + .Add( "text-tiny" ) + .Add( "text-foreground-400" ) + .ToString(); + + public static string GetStyles( LumexRadioGroup radioGroup ) + { + return ElementClass.Empty() + .Add( _base ) + .Add( radioGroup.Classes?.Root ) + .Add( radioGroup.Class ) + .ToString(); + } + + public static string GetLabelStyles( LumexRadioGroup radioGroup ) + { + return ElementClass.Empty() + .Add( _label ) + .Add( radioGroup.Classes?.Label ) + .ToString(); + } + + public static string GetWrapperStyles( LumexRadioGroup radioGroup ) + { + return ElementClass.Empty() + .Add( _wrapper ) + .Add( radioGroup.Classes?.Wrapper ) + .ToString(); + } + + public static string GetDescriptionStyles( LumexRadioGroup radioGroup ) + { + return ElementClass.Empty() + .Add( _description ) + .Add( radioGroup.Classes?.Description ) + .ToString(); + } +} \ No newline at end of file diff --git a/src/LumexUI/Styles/Utils.cs b/src/LumexUI/Styles/Utils.cs index d04e7cf0..dad84ef5 100644 --- a/src/LumexUI/Styles/Utils.cs +++ b/src/LumexUI/Styles/Utils.cs @@ -24,6 +24,10 @@ internal class Utils .Add( "group-focus-visible:ring-offset-2" ) .Add( "group-focus-visible:ring-offset-background" ) .ToString(); + + public readonly static string ReduceMotion = ElementClass.Empty() + .Add( "reduce-motion:transition-none" ) + .ToString(); //public readonly static string GroupDataFocusVisible = ElementClass.Empty() // .Add( "outline-none" ) diff --git a/tests/LumexUI.Tests/Components/RadioGroup/RadioGroupTests.cs b/tests/LumexUI.Tests/Components/RadioGroup/RadioGroupTests.cs new file mode 100644 index 00000000..d0c94d60 --- /dev/null +++ b/tests/LumexUI.Tests/Components/RadioGroup/RadioGroupTests.cs @@ -0,0 +1,344 @@ +// 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.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; + +using TailwindMerge; + +namespace LumexUI.Tests.Components; + +public class RadioGroupTests : TestContext +{ + public RadioGroupTests() + { + Services.AddSingleton(); + } + + [Fact] + public void RadioGroup_ShouldRenderCorrectly() + { + const string groupName = "OfficerChoice"; + + var action = StarfleetOfficers( groupName ); + + action.Should().NotThrow(); + + var cut = action.Invoke(); + var radioButtons = cut.FindComponents>(); + + radioButtons.Count.Should().Be( 4 ); + + radioButtons[0].Instance.Value.Should().Be( "Freeman" ); + radioButtons[0].Instance.Context.GroupName.Should().Be( groupName ); + radioButtons[0].Markup.Should().Contain( "Beckett Mariner" ); + + radioButtons[1].Instance.Value.Should().Be( "Boims" ); + radioButtons[1].Instance.Context.GroupName.Should().Be( groupName ); + radioButtons[1].Markup.Should().Contain( "Brad Boimler" ); + + radioButtons[2].Instance.Value.Should().Be( "Mistress" ); + radioButtons[2].Instance.Context.GroupName.Should().Be( groupName ); + radioButtons[2].Markup.Should().Contain( "D'Vana Tendi" ); + + radioButtons[3].Instance.Value.Should().Be( "Samanthan" ); + radioButtons[3].Instance.Context.GroupName.Should().Be( groupName ); + radioButtons[3].Markup.Should().Contain( "Sam Rutherford" ); + } + + [Fact] + public void RadioGroup_ValueGetsSetOnRadioSelection() + { + var action = StarfleetOfficers( "StarfleetOfficers", "Freeman" ); + + action.Should().NotThrow(); + + var cut = action.Invoke(); + var radioGroup = cut.Instance; + var radioButtons = cut.FindComponents>(); + + radioButtons.Count.Should().Be( 4 ); + radioGroup.Value.Should().NotBe( "Mistress" ); + radioButtons[0].Instance.GetSelectedState().Should().BeTrue(); + + var eventArgs = new ChangeEventArgs + { + Value = "Mistress" + }; + + radioButtons[2].Find( "input" ).Change( eventArgs ); + + radioGroup.Value.Should().Be( "Mistress" ); + radioButtons[2].Instance.GetSelectedState().Should().BeTrue(); + radioButtons[1].Instance.GetSelectedState().Should().BeFalse(); + } + + [Theory] + [InlineData( true, false )] + [InlineData( false, true )] + public void RadioGroup_ValueDoesNotChangeWhenReadOnlyOrDisabled( bool isReadOnly, bool isDisabled ) + { + var action = StarfleetOfficers( groupName: "StarfleetOfficers", selectedValue: "Boims", isReadOnly, isDisabled ); + + action.Should().NotThrow(); + + var cut = action.Invoke(); + var radioGroup = cut.Instance; + var radioButtons = cut.FindComponents>(); + + radioButtons.Count.Should().Be( 4 ); + + var eventArgs = new ChangeEventArgs + { + Value = "Mistress" + }; + + radioButtons[2].Find( "input" ).Change( eventArgs ); + + radioGroup.Value.Should().NotBe( "Mistress" ); + radioButtons[0].Instance.GetSelectedState().Should().BeFalse(); + radioButtons[1].Instance.GetSelectedState().Should().BeTrue(); + radioButtons[2].Instance.GetSelectedState().Should().BeFalse(); + radioButtons[3].Instance.GetSelectedState().Should().BeFalse(); + } + + [Theory] + [InlineData( "true" )] + [InlineData( "false" )] + [InlineData( "foobool" )] + [InlineData( "" )] + [InlineData( null )] + public void RadioGroup_BooleansAreParsedProperly( string? boolString ) + { + var action = () => RenderComponent>( c => c + .AddChildContent>( r => r + .Add( p => p.Value, true ) + .AddChildContent( "Yes" ) + ) + .AddChildContent>( r => r + .Add( p => p.Value, false ) + .AddChildContent( "No" ) + ) + ); + + action.Should().NotThrow(); + + var cut = action.Invoke(); + var radioGroup = cut.Instance; + var radioButtons = cut.FindComponents>(); + + radioButtons.Count.Should().Be( 2 ); + radioGroup.Value.Should().BeFalse(); + radioButtons[0].Instance.GetSelectedState().Should().BeFalse(); + radioButtons[1].Instance.GetSelectedState().Should().BeTrue(); + + var boolEvent = new ChangeEventArgs + { + Value = boolString + }; + + radioButtons[0].Find( "input" ).Change( boolEvent ); + + switch( boolString?.ToLower() ) + { + case "true": + radioGroup.Value.Should().BeTrue(); + radioButtons[0].Instance.GetSelectedState().Should().BeTrue(); + radioButtons[1].Instance.GetSelectedState().Should().BeFalse(); + break; + case "": + case "foobool": + case null: + case "false": + radioGroup.Value.Should().BeFalse(); + radioButtons[0].Instance.GetSelectedState().Should().BeFalse(); + radioButtons[1].Instance.GetSelectedState().Should().BeTrue(); + break; + default: + throw new InvalidOperationException( "Invalid boolean string" ); + } + } + + [Theory] + [InlineData( "true" )] + [InlineData( "false" )] + [InlineData( "foobool" )] + [InlineData( "" )] + [InlineData( null )] + public void RadioGroup_NullableBooleansAreSupported( string? boolString ) + { + var action = () => RenderComponent>( c => c + .AddChildContent>( r => r + .Add( p => p.Value, true ) + .AddChildContent( "Yes" ) + ) + .AddChildContent>( r => r + .Add( p => p.Value, false ) + .AddChildContent( "No" ) + ) + ); + + action.Should().NotThrow(); + + var cut = action.Invoke(); + var radioGroup = cut.Instance; + var radioButtons = cut.FindComponents>(); + + radioButtons.Count.Should().Be( 2 ); + radioGroup.Value.Should().BeNull(); + radioButtons[0].Instance.GetSelectedState().Should().BeFalse(); + radioButtons[1].Instance.GetSelectedState().Should().BeFalse(); + + var boolEvent = new ChangeEventArgs + { + Value = boolString + }; + + radioButtons[0].Find( "input" ).Change( boolEvent ); + + switch( boolString?.ToLower() ) + { + case "true": + radioGroup.Value.Should().NotBeNull(); + radioButtons[0].Instance.GetSelectedState().Should().BeTrue(); + radioButtons[1].Instance.GetSelectedState().Should().BeFalse(); + break; + case "false": + radioGroup.Value.Should().NotBeNull(); + radioButtons[0].Instance.GetSelectedState().Should().BeFalse(); + radioButtons[1].Instance.GetSelectedState().Should().BeTrue(); + break; + case "": + case "foobool": // Special case that should return null on a nullable boolean because it can't be parsed + case null: + radioGroup.Value.Should().BeNull(); + radioButtons[0].Instance.GetSelectedState().Should().BeFalse(); + radioButtons[1].Instance.GetSelectedState().Should().BeFalse(); + break; + default: + throw new InvalidOperationException( "Invalid boolean string" ); + } + } + + [Theory] + [InlineData( "1" )] + [InlineData( "3" )] + [InlineData( "foobool" )] + [InlineData( "" )] + [InlineData( null )] + public void RadioGroup_NullableValueTypesAreSupported( string? valueString ) + { + var action = () => RenderComponent>( c => c + .AddChildContent>( r => r + .Add( p => p.Value, 1 ) + .AddChildContent( "One" ) + ) + .AddChildContent>( r => r + .Add( p => p.Value, 100 ) + .AddChildContent( "One Hundred" ) + ) + ); + + action.Should().NotThrow(); + } + + [Theory] + [InlineData( "1" )] + [InlineData( "One" )] + [InlineData( "Two" )] + [InlineData( "One Hundred" )] + [InlineData( "No Answer" )] + [InlineData( "" )] + [InlineData( null )] + public void RadioGroup_NullableReferenceTypesAreSupported( string? stringValue ) + { + var action = () => RenderComponent>( c => c + .AddChildContent>( r => r + .Add( p => p.Value, "One" ) + .AddChildContent( "One" ) + ) + .AddChildContent>( r => r + .Add( p => p.Value, "One Hundred" ) + .AddChildContent( "One Hundred" ) + ) + .AddChildContent>( r => r + .Add( p => p.Value, "No Answer" ) + .AddChildContent( "No Answer" ) + ) + ); + + action.Should().NotThrow(); + + var cut = action.Invoke(); + var radioGroup = cut.Instance; + var radioButtons = cut.FindComponents>(); + + radioButtons.Count.Should().Be( 3 ); + radioGroup.Value.Should().BeNull(); + radioButtons[0].Instance.GetSelectedState().Should().BeFalse(); + radioButtons[1].Instance.GetSelectedState().Should().BeFalse(); + radioButtons[2].Instance.GetSelectedState().Should().BeFalse(); + + var boolEvent = new ChangeEventArgs + { + Value = stringValue + }; + + radioButtons[0].Find( "input" ).Change( boolEvent ); + + switch( stringValue?.ToLower() ) + { + case "one": + radioGroup.Value.Should().Be( "One" ); + radioButtons[0].Instance.GetSelectedState().Should().BeTrue(); + radioButtons[1].Instance.GetSelectedState().Should().BeFalse(); + radioButtons[2].Instance.GetSelectedState().Should().BeFalse(); + break; + case "one hundred": + radioGroup.Value.Should().Be( "One Hundred" ); + radioButtons[0].Instance.GetSelectedState().Should().BeFalse(); + radioButtons[1].Instance.GetSelectedState().Should().BeTrue(); + radioButtons[2].Instance.GetSelectedState().Should().BeFalse(); + break; + case "no answer": + radioGroup.Value.Should().Be( "No Answer" ); + radioButtons[0].Instance.GetSelectedState().Should().BeFalse(); + radioButtons[1].Instance.GetSelectedState().Should().BeFalse(); + radioButtons[2].Instance.GetSelectedState().Should().BeTrue(); + break; + default: + radioGroup.Value.Should().Be( stringValue ); + radioButtons[0].Instance.GetSelectedState().Should().BeFalse(); + radioButtons[1].Instance.GetSelectedState().Should().BeFalse(); + radioButtons[2].Instance.GetSelectedState().Should().BeFalse(); + break; + } + } + + private Func>> StarfleetOfficers( string groupName, string? selectedValue = null, bool isReadOnly = false, bool isDisabled = false ) + { + return () => RenderComponent>( g => g + .Add( p => p.Label, "Select Officer" ) + .Add( p => p.Description, "Select the officer you'd prefer to lead the away mission" ) + .Add( p => p.Name, groupName ) + .Add( p => p.Disabled, isDisabled ) + .Add( p => p.ReadOnly, isReadOnly ) + .Add( p => p.Value, selectedValue ) + .AddChildContent>( r => r + .Add( p => p.Value, "Freeman" ) + .AddChildContent( "Beckett Mariner" ) ) + .AddChildContent>( r => r + .Add( p => p.Value, "Boims" ) + .AddChildContent( "Brad Boimler" ) ) + .AddChildContent>( r => r + .Add( p => p.Value, "Mistress" ) + .AddChildContent( "D'Vana Tendi" ) ) + .AddChildContent>( r => r + .Add( p => p.Value, "Samanthan" ) + .AddChildContent( "Sam Rutherford" ) ) + ); + } +} \ No newline at end of file diff --git a/tests/LumexUI.Tests/Components/RadioGroup/RadioTests.cs b/tests/LumexUI.Tests/Components/RadioGroup/RadioTests.cs new file mode 100644 index 00000000..60226c82 --- /dev/null +++ b/tests/LumexUI.Tests/Components/RadioGroup/RadioTests.cs @@ -0,0 +1,31 @@ +// 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 RadioTests : TestContext +{ + public RadioTests() + { + Services.AddSingleton(); + } + + [Fact] + public void Radio_MustBeInsideRadioGroup() + { + var action = () => + RenderComponent>( p => + p.Add( r => r.Value, "tallinn" ) + .AddChildContent( "Tallinn" ) + ); + + action.Should().ThrowExactly( "LumexRadio can only be used inside a LumexRadioGroup component" ); + } +} \ No newline at end of file diff --git a/tests/LumexUI.Tests/LumexUI.Tests.csproj b/tests/LumexUI.Tests/LumexUI.Tests.csproj index 884c105c..ed5ef11e 100644 --- a/tests/LumexUI.Tests/LumexUI.Tests.csproj +++ b/tests/LumexUI.Tests/LumexUI.Tests.csproj @@ -16,7 +16,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + all