From eb21c1cac5b0723d6f7a244b9f7be44d029bf365 Mon Sep 17 00:00:00 2001
From: creanium
Date: Sat, 28 Dec 2024 08:54:53 -0700
Subject: [PATCH] feat(components): introduce Radio Group component (#113)
* Add Radio basis
* Add LumexRadioGroup
* Build out Radio and RadioGroup
And add styles
* Add styles and align with Blazor Radio control
* Clean up functionality
- Use appropriate base classes
- Improve link between Context-RadioGroup-Radio
* FIx circular reference/infinite loop
* Add test and docs
* Remove errant bracket
* More tests
* Add more RadioGroup tests
Fix missing name attribute too
* Update Description.razor
* Fix styles
* Add support for Orientation and fix rendering
Also build out more of the docs
* Code organization to fit within convention
And add more code comments and documentation
* Updated documentation
* Add custom style example to Radio
* Remove duplicate file declaration
* Fix build failure after merge
* Update NuGet non-breaking packages
Fixes vulnerability in System.Text.Json 8.0.0
* Fix issue preventing proper selection
* Documentation updates
* Fix failing tests
* Add docs footer
* Add more test coverage
* Update CustomStyles.razor
* PR comments and test updates
* docs: update navigation
* nits
* docs: simplified examples
* docs: add an example showcasing 2-way data binding
* chore: shorten the introduction in the README
* docs(footer): cleanup
---------
Co-authored-by: desmondinho
---
.editorconfig | 2 +-
.gitignore | 6 +
LumexUI.sln | 2 +
README.md | 9 +
.../Common/Navigation/NavigationStore.cs | 1 +
.../Components/DocsFooter.razor | 24 +-
.../Components/DocsSlotsSection.razor | 5 +-
.../LumexUI.Docs.Client.csproj | 4 +-
.../RadioGroup/Examples/Colors.razor | 9 +
.../RadioGroup/Examples/CustomStyles.razor | 69 ++++
.../RadioGroup/Examples/Description.razor | 16 +
.../RadioGroup/Examples/Disabled.razor | 7 +
.../RadioGroup/Examples/Label.razor | 7 +
.../Examples/OptionDescriptions.razor | 14 +
.../RadioGroup/Examples/ReadOnly.razor | 7 +
.../RadioGroup/Examples/Sizes.razor | 5 +
.../Examples/TwoWayDataBinding.razor | 37 ++
.../RadioGroup/Examples/Usage.razor | 7 +
.../RadioGroup/Examples/_Orientation.razor | 6 +
.../RadioGroup/PreviewCodes/Colors.razor | 5 +
.../PreviewCodes/CustomStyles.razor | 5 +
.../RadioGroup/PreviewCodes/Description.razor | 5 +
.../RadioGroup/PreviewCodes/Disabled.razor | 5 +
.../RadioGroup/PreviewCodes/Label.razor | 5 +
.../PreviewCodes/OptionDescriptions.razor | 5 +
.../RadioGroup/PreviewCodes/Orientation.razor | 5 +
.../RadioGroup/PreviewCodes/ReadOnly.razor | 5 +
.../RadioGroup/PreviewCodes/Sizes.razor | 5 +
.../PreviewCodes/TwoWayDataBinding.razor | 5 +
.../RadioGroup/PreviewCodes/Usage.razor | 5 +
.../Components/RadioGroup/RadioGroup.razor | 167 +++++++++
.../LumexUI.Docs.Generator.csproj | 2 +-
docs/LumexUI.Docs/LumexUI.Docs.csproj | 2 +-
.../Common/Interfaces/IComponentContext.cs | 2 +-
.../Radio/IRadioGroupValueProvider.cs | 17 +
src/LumexUI/Components/Radio/LumexRadio.razor | 42 +++
.../Components/Radio/LumexRadio.razor.cs | 132 +++++++
.../Components/Radio/LumexRadioGroup.razor | 25 ++
.../Components/Radio/LumexRadioGroup.razor.cs | 188 ++++++++++
.../Components/Radio/RadioGroupContext.cs | 43 +++
.../Components/Radio/RadioGroupSlots.cs | 32 ++
src/LumexUI/Components/Radio/RadioSlots.cs | 42 +++
src/LumexUI/Icons/Brands.cs | 1 +
src/LumexUI/Infrastructure/InternalNavLink.cs | 2 +
src/LumexUI/LumexUI.csproj | 2 +-
src/LumexUI/Scripts/Plugin/transitions.js | 5 +
src/LumexUI/Styles/Radio.cs | 301 +++++++++++++++
src/LumexUI/Styles/Utils.cs | 4 +
.../Components/RadioGroup/RadioGroupTests.cs | 344 ++++++++++++++++++
.../Components/RadioGroup/RadioTests.cs | 31 ++
tests/LumexUI.Tests/LumexUI.Tests.csproj | 2 +-
51 files changed, 1666 insertions(+), 12 deletions(-)
create mode 100644 docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/Colors.razor
create mode 100644 docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/CustomStyles.razor
create mode 100644 docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/Description.razor
create mode 100644 docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/Disabled.razor
create mode 100644 docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/Label.razor
create mode 100644 docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/OptionDescriptions.razor
create mode 100644 docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/ReadOnly.razor
create mode 100644 docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/Sizes.razor
create mode 100644 docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/TwoWayDataBinding.razor
create mode 100644 docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/Usage.razor
create mode 100644 docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/Examples/_Orientation.razor
create mode 100644 docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Colors.razor
create mode 100644 docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/CustomStyles.razor
create mode 100644 docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Description.razor
create mode 100644 docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Disabled.razor
create mode 100644 docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Label.razor
create mode 100644 docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/OptionDescriptions.razor
create mode 100644 docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Orientation.razor
create mode 100644 docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/ReadOnly.razor
create mode 100644 docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Sizes.razor
create mode 100644 docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/TwoWayDataBinding.razor
create mode 100644 docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/PreviewCodes/Usage.razor
create mode 100644 docs/LumexUI.Docs.Client/Pages/Components/RadioGroup/RadioGroup.razor
create mode 100644 src/LumexUI/Components/Radio/IRadioGroupValueProvider.cs
create mode 100644 src/LumexUI/Components/Radio/LumexRadio.razor
create mode 100644 src/LumexUI/Components/Radio/LumexRadio.razor.cs
create mode 100644 src/LumexUI/Components/Radio/LumexRadioGroup.razor
create mode 100644 src/LumexUI/Components/Radio/LumexRadioGroup.razor.cs
create mode 100644 src/LumexUI/Components/Radio/RadioGroupContext.cs
create mode 100644 src/LumexUI/Components/Radio/RadioGroupSlots.cs
create mode 100644 src/LumexUI/Components/Radio/RadioSlots.cs
create mode 100644 src/LumexUI/Styles/Radio.cs
create mode 100644 tests/LumexUI.Tests/Components/RadioGroup/RadioGroupTests.cs
create mode 100644 tests/LumexUI.Tests/Components/RadioGroup/RadioTests.cs
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+@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