Skip to content

Commit

Permalink
feat(components): add checkbox component (#21)
Browse files Browse the repository at this point in the history
* feat(components): add the base class for inputs

* feat(components): add basic checkbox implementation

* feat(components): add `Radius`, `Colors` and `Size` parameters to the input base

* feat(components): customize checkbox appearance

* feat(components): allow passing custom check icon to the checkbox component

* feat(components): add slots to the checkbox component

* feat(components): add `ReadOnly` parameter to the input base component

* feat(components): add keyboard navigation and focus ring

* test(components): add tests for the checkbox component
  • Loading branch information
desmondinho authored Jun 7, 2024
1 parent 76b2eb1 commit 8cbc234
Show file tree
Hide file tree
Showing 13 changed files with 795 additions and 8 deletions.
2 changes: 1 addition & 1 deletion src/LumexUI.Utilities/ElementClass.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
namespace LumexUI.Utilities;

/// <summary>
/// Represents a CSS class for a rendered element.
/// Represents a CSS class for the rendered element.
/// </summary>
public record struct ElementClass
{
Expand Down
2 changes: 1 addition & 1 deletion src/LumexUI.Utilities/ElementStyle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
namespace LumexUI.Utilities;

/// <summary>
/// Represents an in-line style for a rendered element.
/// Represents an in-line style for the rendered element.
/// </summary>
public record struct ElementStyle
{
Expand Down
175 changes: 175 additions & 0 deletions src/LumexUI/Components/Bases/LumexInputBase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// 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

// Some of the code was taken from
// https://github.com/dotnet/aspnetcore/blob/main/src/Components/Web/src/Forms/InputBase.cs

using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;

using LumexUI.Common;

using Microsoft.AspNetCore.Components;

namespace LumexUI;

public abstract class LumexInputBase<TValue> : LumexComponentBase
{
/// <summary>
/// Gets or sets a value indicating whether the input is disabled.
/// </summary>
[Parameter] public bool Disabled { get; set; }

/// <summary>
/// Gets or sets a value indicating whether the input is read-only.
/// </summary>
[Parameter] public bool ReadOnly { get; set; }

/// <summary>
/// Gets or sets a color of the input.
/// </summary>
/// <remarks>
/// The default is <see cref="ThemeColor.Default"/>
/// </remarks>
[Parameter] public ThemeColor Color { get; set; }

/// <summary>
/// Gets or sets the border radius of the input.
/// </summary>
/// <remarks>
/// The default is <see cref="Radius.Medium"/>
/// </remarks>
[Parameter] public Radius Radius { get; set; } = Radius.Medium;

/// <summary>
/// Gets or sets the size of the input.
/// </summary>
/// <remarks>
/// Default value is <see cref="Size.Medium"/>
/// </remarks>
[Parameter] public Size Size { get; set; } = Size.Medium;

/// <summary>
/// Gets or sets the value of the input. This should be used with two-way binding.
/// </summary>
[Parameter] public TValue? Value { get; set; }

/// <summary>
/// Gets or sets a callback that updates the bound value.
/// </summary>
[Parameter] public EventCallback<TValue> ValueChanged { get; set; }

/// <summary>
/// Gets or sets an expression that identifies the bound value.
/// </summary>
[Parameter] public Expression<Func<TValue>>? ValueExpression { get; set; }

/// <summary>
/// Gets or sets the associated <see cref="ElementReference"/>.
/// </summary>
public ElementReference Element { get; protected set; }

/// <summary>
/// Gets or sets the current value of the input.
/// </summary>
protected TValue? CurrentValue
{
get => Value;
set => _ = SetCurrentValueAsync( value );
}

/// <summary>
/// Gets or sets the current value of the input, represented as a string.
/// </summary>
protected string? CurrentValueAsString
{
get => _parsingFailed ? _incomingValueBeforeParsing : FormatValueAsString( CurrentValue );
set => _ = SetCurrentValueAsStringAsync( value );
}

private bool _parsingFailed;
private bool _hasInitializedParameters;
private string? _incomingValueBeforeParsing;
private Type? _nullableUnderlyingType;

/// <inheritdoc />
public override Task SetParametersAsync( ParameterView parameters )
{
parameters.SetParameterProperties( this );

if( !_hasInitializedParameters )
{
// This is the first run
// Could put this logic in OnInit, but its nice to avoid forcing people who override OnInit to call base.OnInit()
if( ValueExpression is null )
{
throw new InvalidOperationException(
$"{GetType()} requires a value for the '{nameof( ValueExpression )}' parameter. " +
$"Normally this is provided automatically when using '@bind-Value'." );
}

_nullableUnderlyingType = Nullable.GetUnderlyingType( typeof( TValue ) );
_hasInitializedParameters = true;
}

return base.SetParametersAsync( ParameterView.Empty );
}

/// <summary>
/// Parses a string to create an instance of <typeparamref name="TValue"/>.
/// Derived classes can override this to change how <see cref="CurrentValueAsString"/> interprets incoming values.
/// </summary>
/// <param name="value">The string value to be parsed.</param>
/// <param name="result">An instance of <typeparamref name="TValue"/>.</param>
/// <returns><see langword="true"/> if the value could be parsed; otherwise <see langword="false"/>.</returns>
protected abstract bool TryParseValueFromString( string? value, [MaybeNullWhen( false )] out TValue? result );

/// <summary>
/// Sets the current value of the input.
/// </summary>
/// <param name="value">The value to set.</param>
protected internal async Task SetCurrentValueAsync( TValue? value )
{
var hasChanged = !EqualityComparer<TValue>.Default.Equals( value, Value );
if( hasChanged )
{
_parsingFailed = false;

Value = value;
await ValueChanged.InvokeAsync( value );
}
}

/// <summary>
/// Sets the current value of the input, represented as a string.
/// </summary>
/// <param name="value">The value to set.</param>
protected internal async Task SetCurrentValueAsStringAsync( string? value )
{
_incomingValueBeforeParsing = value;

if( _nullableUnderlyingType is not null && string.IsNullOrEmpty( value ) )
{
_parsingFailed = false;
CurrentValue = default!;
}
else if( TryParseValueFromString( value, out var parsedValue ) )
{
_parsingFailed = false;
await SetCurrentValueAsync( parsedValue );
}
else
{
_parsingFailed = true;
}
}

/// <summary>
/// Formats the input value as a string.
/// Derived classes can override this to determine the formatting used for <see cref="CurrentValueAsString"/>.
/// </summary>
/// <param name="value">The value to format.</param>
/// <returns>A string representation of the input value.</returns>
protected virtual string? FormatValueAsString( TValue? value ) => value?.ToString();
}
14 changes: 14 additions & 0 deletions src/LumexUI/Components/Checkbox/CheckboxSlots.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Diagnostics.CodeAnalysis;

using LumexUI.Common;

namespace LumexUI;

[ExcludeFromCodeCoverage]
public class CheckboxSlots : ISlot
{
public string? Root { get; set; }
public string? Wrapper { get; set; }
public string? Icon { get; set; }
public string? Label { get; set; }
}
58 changes: 58 additions & 0 deletions src/LumexUI/Components/Checkbox/LumexCheckbox.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
@namespace LumexUI
@inherits LumexInputBase<bool>

<LumexComponent As="label"
Class="@RootClass"
Style="@RootStyle"
data-checked="@Checked"
tabindex="1"
@attributes="@AdditionalAttributes">
<span class="sr-only">
<input type="checkbox"
value="@true"
checked="@Checked"
disabled="@Disabled"
@ref="@Element"
@attributes="@AdditionalAttributes"
@onchange="@OnChangeAsync" />
</span>

<span class="@WrapperClass">
@if( !string.IsNullOrEmpty( CheckIcon ) )
{
<LumexIcon Icon="@CheckIcon" Class="@IconClass" />
}
else
{
@_renderCheckIcon
}
</span>

@if( ChildContent is not null )
{
<span class="@LabelClass">
@ChildContent
</span>
}
</LumexComponent>

@code {
private void RenderCheckIcon( RenderTreeBuilder __builder )
{
var style = ElementStyle.Empty()
.Add( "transition", "stroke-dashoffset 0.15s linear 0.15s", when: Checked )
.ToString();

<svg class="@IconClass" viewBox="0 0 17 18">
<polyline fill="none"
points="1 9 7 14 15 4"
stroke="currentColor"
stroke-dasharray="22"
stroke-dashoffset="@(Checked ? 44 : 66)"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
style="@style" />
</svg>
}
}
67 changes: 67 additions & 0 deletions src/LumexUI/Components/Checkbox/LumexCheckbox.razor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright (c) LumexUI 2024
// LumexUI licenses this file to you under the MIT license
// See the license here https://github.com/LumexUI/lumexui/blob/main/LICENSE

using LumexUI.Common;
using LumexUI.Styles;

using Microsoft.AspNetCore.Components;

namespace LumexUI;

public partial class LumexCheckbox : LumexInputBase<bool>, ISlotComponent<CheckboxSlots>
{
/// <summary>
/// Gets or sets content to be rendered inside the checkbox.
/// </summary>
[Parameter] public RenderFragment? ChildContent { get; set; }

/// <summary>
/// Gets or sets the icon to be used for indicating a checked state of the checkbox.
/// </summary>
[Parameter] public string? CheckIcon { get; set; }

/// <summary>
/// Gets or sets the CSS class names for the checkbox slots.
/// </summary>
[Parameter] public CheckboxSlots? Classes { get; set; }

private protected override string? RootClass =>
TwMerge.Merge( Checkbox.GetStyles( this ) );

private string WrapperClass =>
TwMerge.Merge( Checkbox.GetWrapperStyles( this ) );

private string IconClass =>
TwMerge.Merge( Checkbox.GetIconStyles( this ) );

private string LabelClass =>
TwMerge.Merge( Checkbox.GetLabelStyles( this ) );

private readonly RenderFragment _renderCheckIcon;

private bool Checked => CurrentValue;

public LumexCheckbox()
{
_renderCheckIcon = RenderCheckIcon;
}

/// <inheritdoc />
protected override bool TryParseValueFromString( string? value, out bool result )
{
throw new NotSupportedException(
$"This component does not parse string inputs. " +
$"Bind to the '{nameof( CurrentValue )}' property, not '{nameof( CurrentValueAsString )}'." );
}

private Task OnChangeAsync( ChangeEventArgs args )
{
if( Disabled || ReadOnly )
{
return Task.CompletedTask;
}

return SetCurrentValueAsync( (bool)args.Value! );
}
}
3 changes: 1 addition & 2 deletions src/LumexUI/Components/Icon/LumexIcon.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ public partial class LumexIcon : LumexComponentBase
[Parameter] public Dimensions Size { get; set; } = new( "24" );

/// <summary>
/// Gets or sets the <seealso href="https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/viewBox">viewBox</seealso>
/// attribute of the SVG element representing the icon.
/// Gets or sets the viewBox attribute of the SVG element representing the icon.
/// </summary>
/// <remarks>
/// The default value is "0 -960 960 960" (<seealso href="https://fonts.google.com/icons">Material Symbols</seealso>)
Expand Down
4 changes: 1 addition & 3 deletions src/LumexUI/Styles/Button.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ private static ElementClass GetHoverStyles( Variant variant, ThemeColor color )

public static string GetStyles( LumexButton button )
{
var styles = new ElementClass()
return ElementClass.Empty()
.Add( _base )
.Add( _disabled, when: button.Disabled )
.Add( _fullWidth, when: button.FullWidth )
Expand All @@ -90,7 +90,5 @@ public static string GetStyles( LumexButton button )
.Add( GetHoverStyles( button.Variant, button.Color ) )
.Add( button.Class )
.ToString();

return styles;
}
}
Loading

0 comments on commit 8cbc234

Please sign in to comment.