diff --git a/docs/LumexUI.Docs.Client/Common/Navigation/NavigationStore.cs b/docs/LumexUI.Docs.Client/Common/Navigation/NavigationStore.cs index daf3008f..1103e691 100644 --- a/docs/LumexUI.Docs.Client/Common/Navigation/NavigationStore.cs +++ b/docs/LumexUI.Docs.Client/Common/Navigation/NavigationStore.cs @@ -32,6 +32,7 @@ public class NavigationStore .Add( new( nameof( LumexChip ), PageStatus.New ) ) .Add( new( nameof( LumexCollapse ) ) ) .Add( new( nameof( LumexDataGrid ) ) ) + .Add( new( nameof( LumexDatebox ), PageStatus.New ) ) .Add( new( nameof( LumexDivider ) ) ) .Add( new( nameof( LumexDropdown ) ) ) .Add( new( nameof( LumexLink ) ) ) @@ -67,6 +68,7 @@ public class NavigationStore .Add( new( nameof( LumexComponent ) ) ) //.Add( nameof( LumexComponentBase ) ) //.Add( nameof( LumexDebouncedInputBase ) ) + .Add( new( nameof( LumexDatebox ) ) ) .Add( new( nameof( LumexDivider ) ) ) .Add( new( nameof( LumexDropdown ) ) ) .Add( new( nameof( LumexDropdownItem ) ) ) diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Datebox.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Datebox.razor new file mode 100644 index 00000000..40f76637 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Datebox.razor @@ -0,0 +1,166 @@ +@page "/docs/components/datebox" +@layout DocsContentLayout + +@using LumexUI.Docs.Client.Pages.Components.Datebox.PreviewCodes + + +

+ The datebox component is almost identical to the + textbox + but includes additional features specific to date input. +

+

+ It supports various date types, including + DateTime, DateTimeOffset, DateOnly, + TimeOnly. Additionally, their nullable counterparts are also supported. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + If the Label parameter is not set, the LabelPlacement + parameter will default to Outside. + + + + + + + + + + + + + + + + +

+ Display an error message below the datebox to indicate validation issues. + You can combine the Invalid and ErrorMessage parameters to show an invalid input. + An error message is shown only when the Invalid parameter is set to true. +

+ + +
+ + +

+ Use @@bind-Value directive or Value and ValueChanged + parameters to manually control datebox value. +

+ +
+
+ + +
+

Datebox

+ +
    +
  • + Class: The CSS class names to style the datebox wrapper. +
  • + +
  • + Classes: The CSS class names to style the datebox slots. +
  • +
+
+ +
+ + + +@code { + [CascadingParameter] + private DocsContentLayout Layout { get; set; } = default!; + + private readonly Heading[] _headings = new Heading[] + { + new("Types"), + new("Usage", [ + new("Disabled"), + new("Read-Only"), + new("Required"), + new("Sizes"), + new("Radius"), + new("Colors"), + new("Variants"), + new("Label Placements"), + new("Clear Button"), + new("Start & End Content"), + new("Description"), + new("Error message"), + new("Two-way Data Binding"), + ]), + new("Custom Styles"), + new("API") + }; + + private readonly Slot[] _slots = new Slot[] + { + new(nameof(InputFieldSlots.Base), "The overall wrapper."), + new(nameof(InputFieldSlots.Label), "The label element."), + new(nameof(InputFieldSlots.MainWrapper), "The wrapper of the input wrapper (when the label is outside)."), + new(nameof(InputFieldSlots.InputWrapper), "The wrapper of the label and the inner wrapper (when the label is inside)."), + new(nameof(InputFieldSlots.InnerWrapper), "The wrapper of the input, start/end content."), + new(nameof(InputFieldSlots.Input), "The input element."), + new(nameof(InputFieldSlots.ClearButton), "The clear button element."), + new(nameof(InputFieldSlots.HelperWrapper), "The wrapper of a description and an error message."), + new(nameof(InputFieldSlots.Description), "The description of the input field."), + new(nameof(InputFieldSlots.ErrorMessage), "The error message of the input field.") + }; + + private readonly string[] _apiComponents = new string[] + { + nameof(LumexDatebox) + }; + + protected override void OnInitialized() + { + Layout.Initialize( + title: "Datebox", + category: "Components", + description: "Datebox allows users to enter and edit date values.", + headings: _headings, + linksProps: new ComponentLinksProps("Datebox", isServer: false) + ); + } +} \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/ClearButton.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/ClearButton.razor new file mode 100644 index 00000000..8e8f114b --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/ClearButton.razor @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/Colors.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/Colors.razor new file mode 100644 index 00000000..bc0a3e93 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/Colors.razor @@ -0,0 +1,27 @@ +
+ @foreach (ThemeColor color in _colors) + { +
+ + + + @color + +
+ } +
+ +@code { + private ThemeColor[] _colors = [ + ThemeColor.Default, + ThemeColor.Primary, + ThemeColor.Secondary, + ThemeColor.Success, + ThemeColor.Warning, + ThemeColor.Danger, + ThemeColor.Info + ]; +} \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/CustomStyles.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/CustomStyles.razor new file mode 100644 index 00000000..4402aa4e --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/CustomStyles.razor @@ -0,0 +1,30 @@ + + + + + + +@code { + private InputFieldSlots _classes = new() + { + Label = "text-default-700", + InnerWrapper = "bg-transparent", + InputWrapper = ElementClass.Empty() + .Add("shadow-xl") + .Add("bg-default-200/50") + .Add("backdrop-blur-xl") + .Add("backdrop-saturate-200") + .Add("hover:bg-default-200/70") + .Add("group-data-[focus=true]:bg-default-200/85") + .ToString(), + Input = ElementClass.Empty() + .Add("bg-transparent") + .Add("text-default-900") + .Add("placeholder:text-default-500") + .ToString() + }; +} \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/Description.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/Description.razor new file mode 100644 index 00000000..5040ecc0 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/Description.razor @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/Disabled.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/Disabled.razor new file mode 100644 index 00000000..d17c5d7f --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/Disabled.razor @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/ErrorMessage.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/ErrorMessage.razor new file mode 100644 index 00000000..d3782424 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/ErrorMessage.razor @@ -0,0 +1,64 @@ +@using FluentValidation +@using FluentValidation.Results + + + +@code { + private User _user = new(); + private UserValidator _userValidator = new(); + + protected override void OnInitialized() + { + _user.Date = DateTime.Today; + Validate(); + } + + private void OnDateChange(DateTime? value) + { + _user.Date = value; + Validate(); + } + + private void Validate() + { + ValidationResult result = _userValidator.Validate(_user); + + if (!result.IsValid) + { + _userValidator.DateErrorMessage = result.Errors + .Where(failure => failure.PropertyName == nameof(User.Date)) + .Select(failure => failure.ErrorMessage) + .FirstOrDefault(); + } + else + { + _userValidator.DateErrorMessage = null; + } + } + + public class User + { + public DateTime? Date { get; set; } + } + + public class UserValidator : AbstractValidator + { + public string? DateErrorMessage { get; set; } + + public UserValidator() + { + RuleFor(user => user.Date) + .NotEmpty() + .WithMessage("Birth date is required"); + } + } +} \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/LabelPlacements.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/LabelPlacements.razor new file mode 100644 index 00000000..a30c9a06 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/LabelPlacements.razor @@ -0,0 +1,22 @@ +
+ @foreach (LabelPlacement placement in _labelPlacements) + { +
+ + + + @placement + +
+ } +
+ +@code { + private LabelPlacement[] _labelPlacements = [ + LabelPlacement.Inside, + LabelPlacement.Outside + ]; +} \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/ReadOnly.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/ReadOnly.razor new file mode 100644 index 00000000..c5d43ebe --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/ReadOnly.razor @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/Required.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/Required.razor new file mode 100644 index 00000000..810d0f49 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/Required.razor @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/Sizes.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/Sizes.razor new file mode 100644 index 00000000..e77b92db --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/Sizes.razor @@ -0,0 +1,21 @@ +
+ @foreach (Size size in _sizes) + { +
+ + + @size +
+ } +
+ +@code { + private Size[] _sizes = [ + Size.Small, + Size.Medium, + Size.Large + ]; +} \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/StartEndContent.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/StartEndContent.razor new file mode 100644 index 00000000..f76dd5bd --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/StartEndContent.razor @@ -0,0 +1,28 @@ +
+
+ + + + Start + +
+ +
+ + + + End + +
+
+ +@code { + private RenderFragment _calendarClockIcon = + @; +} \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/TwoWayDataBinding.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/TwoWayDataBinding.razor new file mode 100644 index 00000000..684b5b61 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/TwoWayDataBinding.razor @@ -0,0 +1,31 @@ +
+
+ + +

+ Value: @_valueOne +

+
+ +
+ + +

+ Value: @_valueTwo +

+
+
+ +@code { + private DateTime? _valueOne; + private DateTime? _valueTwo = DateTime.Today; + + private void OnValueChanged( DateTime? value ) + { + _valueTwo = value; + } +} \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/Usage.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/Usage.razor new file mode 100644 index 00000000..e3fcf48f --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/Usage.razor @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/Variants.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/Variants.razor new file mode 100644 index 00000000..b8543b0d --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/Variants.razor @@ -0,0 +1,23 @@ +
+ @foreach (InputVariant variant in _variants) + { +
+ + + + @variant + +
+ } +
+ +@code { + private InputVariant[] _variants = [ + InputVariant.Flat, + InputVariant.Outlined, + InputVariant.Underlined + ]; +} \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/_Radius.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/_Radius.razor new file mode 100644 index 00000000..a4a59eb5 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/Examples/_Radius.razor @@ -0,0 +1,24 @@ +
+ @foreach (Radius radius in _radii) + { +
+ + + + @radius + +
+ } +
+ +@code { + private Radius[] _radii = [ + Radius.None, + Radius.Small, + Radius.Medium, + Radius.Large + ]; +} \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/ClearButton.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/ClearButton.razor new file mode 100644 index 00000000..a0052c9c --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/ClearButton.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/Colors.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/Colors.razor new file mode 100644 index 00000000..2c1ffff2 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/Colors.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/CustomStyles.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/CustomStyles.razor new file mode 100644 index 00000000..a3688150 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/CustomStyles.razor @@ -0,0 +1,14 @@ +@rendermode InteractiveWebAssembly + + + + + +@code { + private Preview.Slots _classes = new() + { + Background = "mask-none bg-default-50", + Preview = "justify-center" + }; +} \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/Description.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/Description.razor new file mode 100644 index 00000000..962aa713 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/Description.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/Disabled.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/Disabled.razor new file mode 100644 index 00000000..314e3e9d --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/Disabled.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/ErrorMessage.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/ErrorMessage.razor new file mode 100644 index 00000000..1f53195b --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/ErrorMessage.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/LabelPlacements.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/LabelPlacements.razor new file mode 100644 index 00000000..d80b407d --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/LabelPlacements.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/Radius.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/Radius.razor new file mode 100644 index 00000000..e140ec05 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/Radius.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/ReadOnly.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/ReadOnly.razor new file mode 100644 index 00000000..8857b959 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/ReadOnly.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/Required.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/Required.razor new file mode 100644 index 00000000..09284af1 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/Required.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/Sizes.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/Sizes.razor new file mode 100644 index 00000000..7ee671ba --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/Sizes.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/StartEndContent.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/StartEndContent.razor new file mode 100644 index 00000000..06102913 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/StartEndContent.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/TwoWayDataBinding.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/TwoWayDataBinding.razor new file mode 100644 index 00000000..6e1d79e2 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/TwoWayDataBinding.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/Usage.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/Usage.razor new file mode 100644 index 00000000..7f1d77e0 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/Usage.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/Variants.razor b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/Variants.razor new file mode 100644 index 00000000..0ce12f96 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Datebox/PreviewCodes/Variants.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Numbox/Numbox.razor b/docs/LumexUI.Docs.Client/Pages/Components/Numbox/Numbox.razor index 56dd5097..cb724689 100644 --- a/docs/LumexUI.Docs.Client/Pages/Components/Numbox/Numbox.razor +++ b/docs/LumexUI.Docs.Client/Pages/Components/Numbox/Numbox.razor @@ -13,9 +13,7 @@ It supports various numeric types, including short, int, long, float, double, and decimal. - Additionally, their nullable counterparts (e.g., int?, - double?, decimal?) are also supported, - providing flexibility for scenarios where input values may be optional or undefined. + Additionally, their nullable counterparts are also supported.

diff --git a/src/LumexUI/Common/Enums/InputDateType.cs b/src/LumexUI/Common/Enums/InputDateType.cs new file mode 100644 index 00000000..ad70fb54 --- /dev/null +++ b/src/LumexUI/Common/Enums/InputDateType.cs @@ -0,0 +1,37 @@ +// 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.ComponentModel; + +namespace LumexUI.Common; + +/// +/// Specifies the type of the . +/// +public enum InputDateType +{ + /// + /// Represents a date value without a time component. + /// + [Description( "date" )] + Date, + + /// + /// Represents a date and time value without a time zone. + /// + [Description( "datetime-local" )] + DateTimeLocal, + + /// + /// Represents the month component of a date or time value. + /// + [Description( "month" )] + Month, + + /// + /// Represents a time value to indicate a specific time of day without a date component. + /// + [Description( "time" )] + Time, +} \ No newline at end of file diff --git a/src/LumexUI/Common/Enums/InputType.cs b/src/LumexUI/Common/Enums/InputType.cs index 561f9041..8aa2a247 100644 --- a/src/LumexUI/Common/Enums/InputType.cs +++ b/src/LumexUI/Common/Enums/InputType.cs @@ -51,11 +51,5 @@ public enum InputType /// An input field for entering a URL. /// [Description( "url" )] - Url, - - /// - /// An input field for selecting a color. - /// - [Description( "color" )] - Color + Url } diff --git a/src/LumexUI/Components/Bases/LumexDebouncedInputBase.cs b/src/LumexUI/Components/Bases/LumexDebouncedInputBase.cs index ae86bb0b..2e25ef0e 100644 --- a/src/LumexUI/Components/Bases/LumexDebouncedInputBase.cs +++ b/src/LumexUI/Components/Bases/LumexDebouncedInputBase.cs @@ -2,6 +2,8 @@ // 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; namespace LumexUI; @@ -10,116 +12,127 @@ namespace LumexUI; /// Represents a base class for input components with debounced value updates. /// /// The type of the input value. -public abstract class LumexDebouncedInputBase : LumexInputBase, IDisposable +public abstract class LumexDebouncedInputBase : LumexInputFieldBase { - /// - /// Gets or sets the delay, in milliseconds, for debouncing input events. - /// - [Parameter] public int DebounceDelay { get; set; } - - private readonly Debouncer _debouncer; - - private bool _disposed; - - /// - /// Initializes a new instance of the . - /// - public LumexDebouncedInputBase() - { - _debouncer = new Debouncer(); - } - - /// - /// Handles the input event asynchronously, applying a debounce delay if provided. - /// - /// The change event arguments. - /// A representing the asynchronous value input operation. - protected virtual Task OnInputAsync( ChangeEventArgs args ) - { - if( DebounceDelay > 0 ) - { - return _debouncer.DebounceAsync( SetCurrentValueAsStringAsync, (string?)args.Value, DebounceDelay ); - } - - return SetCurrentValueAsStringAsync( (string?)args.Value ); - } - - /// - /// Handles the change event asynchronously. - /// - /// The change event arguments. - /// A representing the asynchronous value change operation. - protected virtual Task OnChangeAsync( ChangeEventArgs args ) - { - return SetCurrentValueAsStringAsync( (string?)args.Value ); - } - - /// - public void Dispose() - { - Dispose( disposing: true ); - GC.SuppressFinalize( this ); - } - - /// - protected virtual void Dispose( bool disposing ) - { - if( !_disposed ) - { - if( disposing ) - { - _debouncer.Dispose(); - } - - _disposed = true; - } - } - - /// - /// Represents a debouncer for handling debounced input events. - /// - private sealed class Debouncer : IDisposable - { - private bool _disposed; - private CancellationTokenSource? _cts; - - public async Task DebounceAsync( Func workItem, string? arg, int milliseconds ) - { - ArgumentNullException.ThrowIfNull( workItem ); - - _cts?.Cancel(); - _cts?.Dispose(); - - var cts = _cts = new CancellationTokenSource(); - using var timer = new PeriodicTimer( TimeSpan.FromMilliseconds( milliseconds ) ); - - while( await timer.WaitForNextTickAsync( cts.Token ) ) - { - // Debounce time has passed without further input; trigger the debounced event - await workItem( arg ); - break; - } - } - - /// - public void Dispose() - { - Dispose( disposing: true ); - GC.SuppressFinalize( this ); - } - - private void Dispose( bool disposing ) - { - if( !_disposed ) - { - if( disposing ) - { - _cts?.Cancel(); - _cts?.Dispose(); - } - - _disposed = true; - } - } - } + /// + /// Gets or sets the delay, in milliseconds, for debouncing input events. + /// + [Parameter] public int DebounceDelay { get; set; } + + /// + /// Gets or sets the input behavior, specifying when the textbox + /// updates its value and triggers validation. + /// + /// + /// The default value is + /// + [Parameter] public InputBehavior Behavior { get; set; } = InputBehavior.OnChange; + + private readonly Debouncer _debouncer; + + /// + /// Initializes a new instance of the . + /// + public LumexDebouncedInputBase() + { + _debouncer = new Debouncer(); + } + + /// + protected override void OnParametersSet() + { + if( DebounceDelay > 0 && Behavior is not InputBehavior.OnInput ) + { + throw new InvalidOperationException( + $"{GetType()} requires '{nameof( InputBehavior.OnInput )}' behavior" + + $" to be used when '{nameof( DebounceDelay )}' is not zero." ); + } + } + + /// + protected override Task OnInputAsync( ChangeEventArgs args ) + { + if( Behavior is not InputBehavior.OnInput ) + { + return Task.CompletedTask; + } + + if( DebounceDelay > 0 ) + { + return _debouncer.DebounceAsync( SetCurrentValueAsStringAsync, (string?)args.Value, DebounceDelay ); + } + + return SetCurrentValueAsStringAsync( (string?)args.Value ); + } + + /// + protected override Task OnChangeAsync( ChangeEventArgs args ) + { + if( Behavior is not InputBehavior.OnChange ) + { + return Task.CompletedTask; + } + + return SetCurrentValueAsStringAsync( (string?)args.Value ); + } + + /// + public override async ValueTask DisposeAsync() + { + _debouncer.Dispose(); + await base.DisposeAsync(); + } + + /// + /// Represents a debouncer for handling debounced input events. + /// + private sealed class Debouncer : IDisposable + { + private bool _disposed; + private CancellationTokenSource? _cts; + + public async Task DebounceAsync( Func workItem, string? arg, int milliseconds ) + { + ObjectDisposedException.ThrowIf( _disposed, this ); + ArgumentNullException.ThrowIfNull( workItem ); + + // Cancel/dispose any pending debounce. + _cts?.Cancel(); + _cts?.Dispose(); + + var cts = _cts = new CancellationTokenSource(); + + if( milliseconds <= 0 ) + { + await workItem( arg ); + return; + } + + try + { + await Task.Delay( milliseconds, cts.Token ); + + // Debounce time has passed without further input; trigger the debounced event. + await workItem( arg ); + } + catch( OperationCanceledException ) when( cts.IsCancellationRequested ) + { + // Expected: new input or disposal cancelled the pending debounce. + } + } + + /// + public void Dispose() + { + if( _disposed ) + { + return; + } + + _disposed = true; + + _cts?.Cancel(); + _cts?.Dispose(); + } + } } diff --git a/src/LumexUI/Components/Bases/LumexInputBase.cs b/src/LumexUI/Components/Bases/LumexInputBase.cs index 5f02ca66..880f320d 100644 --- a/src/LumexUI/Components/Bases/LumexInputBase.cs +++ b/src/LumexUI/Components/Bases/LumexInputBase.cs @@ -221,7 +221,6 @@ protected virtual async Task OnBlurAsync( FocusEventArgs args ) /// A string representation of the input value. protected virtual string? FormatValueAsString( TValue? value ) => value?.ToString(); - /// /// Asynchronously sets the validation message if any. /// diff --git a/src/LumexUI/Components/Bases/LumexInputFieldBase.razor b/src/LumexUI/Components/Bases/LumexInputFieldBase.razor index 4bddcef5..5e83888c 100644 --- a/src/LumexUI/Components/Bases/LumexInputFieldBase.razor +++ b/src/LumexUI/Components/Bases/LumexInputFieldBase.razor @@ -1,5 +1,5 @@ @namespace LumexUI -@inherits LumexDebouncedInputBase +@inherits LumexInputBase @typeparam TValue /// The type of the input value. -public abstract partial class LumexInputFieldBase : LumexDebouncedInputBase, - ISlotComponent, - IAsyncDisposable +public abstract partial class LumexInputFieldBase : LumexInputBase, + ISlotComponent, + IAsyncDisposable { - private const string JavaScriptFile = "./_content/LumexUI/js/components/input.js"; - - /// - /// Gets or sets content to be rendered at the start of the textbox. - /// - [Parameter] public RenderFragment? StartContent { get; set; } - - /// - /// Gets or sets content to be rendered at the end of the textbox. - /// - [Parameter] public RenderFragment? EndContent { get; set; } - - /// - /// Gets or sets the label for the textbox. - /// - [Parameter] public string? Label { get; set; } - - /// - /// Gets or sets the placeholder for the textbox. - /// - [Parameter] public string? Placeholder { get; set; } - - /// - /// Gets or sets the description for the textbox. - /// - [Parameter] public string? Description { get; set; } - - /// - /// Gets or sets the error message for the textbox. - /// This message is displayed only when the textbox is invalid. - /// - [Parameter] public string? ErrorMessage { get; set; } - - /// - /// Gets or sets the variant for the textbox. - /// - /// - /// The default value is - /// - [Parameter] public InputVariant Variant { get; set; } - - /// - /// Gets or sets the input behavior, specifying when the textbox - /// updates its value and triggers validation. - /// - /// - /// The default value is - /// - [Parameter] public InputBehavior Behavior { get; set; } = InputBehavior.OnChange; - - /// - /// Gets or sets the border radius of the textbox. - /// - [Parameter] public Radius? Radius { get; set; } - - /// - /// Gets or sets the placement of the label for the textbox. - /// - /// - /// The default value is - /// - [Parameter] public LabelPlacement LabelPlacement { get; set; } - - /// - /// Gets or sets a value indicating whether the textbox is full-width. - /// - /// - /// The default value is - /// - [Parameter] public bool FullWidth { get; set; } = true; - - /// - /// Gets or sets a value indicating whether the textbox should have a clear button. - /// - [Parameter] public bool Clearable { get; set; } - - /// - /// Gets or sets a value indicating whether the textbox should automatically receive focus. - /// - [Parameter] public bool Autofocus { get; set; } - - /// - /// Gets or sets a callback that is fired when the value in the textbox is cleared. - /// - [Parameter] public EventCallback OnCleared { get; set; } - - /// - /// Gets or sets the CSS class names for the textbox slots. - /// - [Parameter] public InputFieldSlots? Classes { get; set; } - - [Inject] private IJSRuntime JSRuntime { get; set; } = default!; - - /// - /// Gets or sets the validation error mesage of the input. - /// - protected string? ValidationMessage { get; private set; } - - private protected override string? RootClass => - TwMerge.Merge( InputField.GetStyles( this ) ); - - private string? LabelClass => - TwMerge.Merge( InputField.GetLabelStyles( this ) ); - - private string? MainWrapperClass => - TwMerge.Merge( InputField.GetMainWrapperStyles( this ) ); - - private string? InputWrapperClass => - TwMerge.Merge( InputField.GetInputWrapperStyles( this ) ); - - private string? InnerWrapperClass => - TwMerge.Merge( InputField.GetInnerWrapperStyles( this ) ); - - private string? InputClass => - TwMerge.Merge( InputField.GetInputStyles( this ) ); - - private string? ClearButtonClass => - TwMerge.Merge( InputField.GetClearButtonStyles( this ) ); - - private string? HelperWrapperClass => - TwMerge.Merge( InputField.GetHelperWrapperStyles( this ) ); - - private string? DescriptionClass => - TwMerge.Merge( InputField.GetDescriptionStyles( this ) ); - - private string? ErrorMessageClass => - TwMerge.Merge( InputField.GetErrorMessageStyles( this ) ); - - private bool HasHelper => - !string.IsNullOrEmpty( Description ) || - !string.IsNullOrEmpty( ErrorMessage ) || - !string.IsNullOrEmpty( ValidationMessage ); - private bool HasValue => !string.IsNullOrEmpty( CurrentValueAsString ); - private bool ClearButtonVisible => Clearable && HasValue; - private bool FilledOrFocused => - Focused || - HasValue || - StartContent is not null || - !string.IsNullOrEmpty( Placeholder ); - - private readonly RenderFragment _renderMainWrapper; - private readonly RenderFragment _renderInputWrapper; - private readonly RenderFragment _renderHelperWrapper; - - private string _inputType = default!; - private IJSObjectReference _jsModule = default!; - - /// - /// Initializes a new instance of the . - /// - public LumexInputFieldBase() - { - _renderMainWrapper = RenderMainWrapper; - _renderInputWrapper = RenderInputWrapper; - _renderHelperWrapper = RenderHelperWrapper; - - As = "div"; - } - - /// - public override async Task SetParametersAsync( ParameterView parameters ) - { - await base.SetParametersAsync( parameters ); - - if( parameters.TryGetValue( nameof( LabelPlacement ), out var labelPlacement ) ) - { - LabelPlacement = labelPlacement; - } - // Default LabelPlacement to 'Outside' if the label and its placement are not set - else if( !parameters.TryGetValue( nameof( Label ), out var _ ) ) - { - LabelPlacement = LabelPlacement.Outside; - } - } - - /// - protected override void OnParametersSet() - { - if( DebounceDelay > 0 && Behavior is not InputBehavior.OnInput ) - { - throw new InvalidOperationException( - $"{GetType()} requires '{nameof( InputBehavior.OnInput )}' behavior" + - $" to be used when '{nameof( DebounceDelay )}' is not zero." ); - } - } - - /// - protected override async Task OnAfterRenderAsync( bool firstRender ) - { - if( firstRender ) - { - _jsModule = await JSRuntime.InvokeAsync( "import", JavaScriptFile ); - } - - if( Autofocus ) - { - await FocusAsync(); - } - } - - /// - protected override Task OnInputAsync( ChangeEventArgs args ) - { - if( Behavior is not InputBehavior.OnInput ) - { - return Task.CompletedTask; - } - - return base.OnInputAsync( args ); - } - - /// - protected override Task OnChangeAsync( ChangeEventArgs args ) - { - if( Behavior is not InputBehavior.OnChange ) - { - return Task.CompletedTask; - } - - return base.OnChangeAsync( args ); - } - - /// - protected override bool TryParseValueFromString( string? value, [MaybeNullWhen( false )] out TValue result ) - { - return BindConverter.TryConvertTo( value, CultureInfo.InvariantCulture, out result ); - } - - /// - protected override async ValueTask SetValidationMessageAsync() - { - ValidationMessage = await _jsModule.InvokeAsync( "input.getValidationMessage", ElementReference ); - Invalid = !string.IsNullOrEmpty( ErrorMessage ) || - !string.IsNullOrEmpty( ValidationMessage ); - } - - /// - /// Sets the input type for the input field. - /// - /// The input type to set (e.g., "text", "number", "password"). - protected void SetInputType( string type ) - { - _inputType = type; - } - - private async Task FocusInputAsync() - { - if( !Disabled && !ReadOnly ) - { - await FocusAsync(); - } - } - - private async Task ClearAsync( MouseEventArgs args ) - { - await ClearAsyncCore(); - } - - private async Task ClearAsync( KeyboardEventArgs args ) - { - if( args.Code is "Enter" or "Space" ) - { - await ClearAsyncCore(); - } - } - - private async Task ClearAsyncCore() - { - await SetCurrentValueAsync( default ); - await OnCleared.InvokeAsync(); - await FocusAsync(); - } - - /// - public async ValueTask DisposeAsync() - { - try - { - if( _jsModule is not null ) - { - await _jsModule.DisposeAsync(); - } - - Dispose(); - } - catch( Exception ex ) when( ex is JSDisconnectedException or OperationCanceledException ) - { - // The JSRuntime side may routinely be gone already if the reason we're disposing is that - // the client disconnected. This is not an error. - } - } + private const string JavaScriptFile = "./_content/LumexUI/js/components/input.js"; + + /// + /// Gets or sets content to be rendered at the start of the textbox. + /// + [Parameter] public RenderFragment? StartContent { get; set; } + + /// + /// Gets or sets content to be rendered at the end of the textbox. + /// + [Parameter] public RenderFragment? EndContent { get; set; } + + /// + /// Gets or sets the label for the textbox. + /// + [Parameter] public string? Label { get; set; } + + /// + /// Gets or sets the placeholder for the textbox. + /// + [Parameter] public string? Placeholder { get; set; } + + /// + /// Gets or sets the description for the textbox. + /// + [Parameter] public string? Description { get; set; } + + /// + /// Gets or sets the error message for the textbox. + /// This message is displayed only when the textbox is invalid. + /// + [Parameter] public string? ErrorMessage { get; set; } + + /// + /// Gets or sets the variant for the textbox. + /// + /// + /// The default value is + /// + [Parameter] public InputVariant Variant { get; set; } + + /// + /// Gets or sets the border radius of the textbox. + /// + [Parameter] public Radius? Radius { get; set; } + + /// + /// Gets or sets the placement of the label for the textbox. + /// + /// + /// The default value is + /// + [Parameter] public LabelPlacement LabelPlacement { get; set; } + + /// + /// Gets or sets a value indicating whether the textbox is full-width. + /// + /// + /// The default value is + /// + [Parameter] public bool FullWidth { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the textbox should have a clear button. + /// + [Parameter] public bool Clearable { get; set; } + + /// + /// Gets or sets a value indicating whether the textbox should automatically receive focus. + /// + [Parameter] public bool Autofocus { get; set; } + + /// + /// Gets or sets a callback that is fired when the value in the textbox is cleared. + /// + [Parameter] public EventCallback OnCleared { get; set; } + + /// + /// Gets or sets the CSS class names for the textbox slots. + /// + [Parameter] public InputFieldSlots? Classes { get; set; } + + [Inject] private IJSRuntime JSRuntime { get; set; } = default!; + + /// + /// Gets or sets the validation error message of the input. + /// + protected string? ValidationMessage { get; private set; } + + /// + /// Gets or sets a value indicating whether the input is filled or focused. + /// + protected virtual bool FilledOrFocused => + Focused || + HasValue || + StartContent is not null || + !string.IsNullOrEmpty( Placeholder ); + + private protected override string? RootClass => + TwMerge.Merge( InputField.GetStyles( this ) ); + + private string? LabelClass => + TwMerge.Merge( InputField.GetLabelStyles( this ) ); + + private string? MainWrapperClass => + TwMerge.Merge( InputField.GetMainWrapperStyles( this ) ); + + private string? InputWrapperClass => + TwMerge.Merge( InputField.GetInputWrapperStyles( this ) ); + + private string? InnerWrapperClass => + TwMerge.Merge( InputField.GetInnerWrapperStyles( this ) ); + + private string? InputClass => + TwMerge.Merge( InputField.GetInputStyles( this ) ); + + private string? ClearButtonClass => + TwMerge.Merge( InputField.GetClearButtonStyles( this ) ); + + private string? HelperWrapperClass => + TwMerge.Merge( InputField.GetHelperWrapperStyles( this ) ); + + private string? DescriptionClass => + TwMerge.Merge( InputField.GetDescriptionStyles( this ) ); + + private string? ErrorMessageClass => + TwMerge.Merge( InputField.GetErrorMessageStyles( this ) ); + + private bool HasHelper => + !string.IsNullOrEmpty( Description ) || + !string.IsNullOrEmpty( ErrorMessage ) || + !string.IsNullOrEmpty( ValidationMessage ); + private bool HasValue => !string.IsNullOrEmpty( CurrentValueAsString ); + private bool ClearButtonVisible => ( Clearable || OnCleared.HasDelegate ) && HasValue; + + private readonly RenderFragment _renderMainWrapper; + private readonly RenderFragment _renderInputWrapper; + private readonly RenderFragment _renderHelperWrapper; + + private string _inputType = default!; + private IJSObjectReference _jsModule = default!; + + /// + /// Initializes a new instance of the . + /// + public LumexInputFieldBase() + { + _renderMainWrapper = RenderMainWrapper; + _renderInputWrapper = RenderInputWrapper; + _renderHelperWrapper = RenderHelperWrapper; + + As = "div"; + } + + /// + public override async Task SetParametersAsync( ParameterView parameters ) + { + await base.SetParametersAsync( parameters ); + + if( parameters.TryGetValue( nameof( LabelPlacement ), out var labelPlacement ) ) + { + LabelPlacement = labelPlacement; + } + // Default LabelPlacement to 'Outside' if the label and its placement are not set + else if( !parameters.TryGetValue( nameof( Label ), out var _ ) ) + { + LabelPlacement = LabelPlacement.Outside; + } + } + + /// + protected override async Task OnAfterRenderAsync( bool firstRender ) + { + if( firstRender ) + { + _jsModule = await JSRuntime.InvokeAsync( "import", JavaScriptFile ); + + if( Autofocus ) + { + await FocusAsync(); + } + } + } + + /// + /// Handles input events asynchronously. + /// + /// + /// Override this method in a derived class to implement custom logic for handling input changes. + /// + /// The change event arguments. + /// A representing the asynchronous value input operation. + protected abstract Task OnInputAsync( ChangeEventArgs args ); + + /// + /// Handles change events asynchronously. + /// + /// + /// Override this method in a derived class to implement custom logic that responds to change events. + /// + /// The change event arguments. + /// A representing the asynchronous value change operation. + protected abstract Task OnChangeAsync( ChangeEventArgs args ); + + /// + protected override bool TryParseValueFromString( string? value, [MaybeNullWhen( false )] out TValue result ) + { + return BindConverter.TryConvertTo( value, CultureInfo.InvariantCulture, out result ); + } + + /// + protected override async ValueTask SetValidationMessageAsync() + { + ValidationMessage = await _jsModule.InvokeAsync( "input.getValidationMessage", ElementReference ); + Invalid = !string.IsNullOrEmpty( ErrorMessage ) || + !string.IsNullOrEmpty( ValidationMessage ); + } + + /// + /// Sets the input type for the input field. + /// + /// The input type to set (e.g., "text", "number", "password"). + protected void SetInputType( string type ) + { + _inputType = type; + } + + private async Task FocusInputAsync() + { + if( !Disabled && !ReadOnly ) + { + await FocusAsync(); + } + } + + private async Task ClearAsync( MouseEventArgs args ) + { + await ClearAsyncCore(); + } + + private async Task ClearAsync( KeyboardEventArgs args ) + { + if( args.Code is "Enter" or "Space" ) + { + await ClearAsyncCore(); + } + } + + private async Task ClearAsyncCore() + { + await SetCurrentValueAsync( default ); + await OnCleared.InvokeAsync(); + await FocusAsync(); + } + + /// + public virtual async ValueTask DisposeAsync() + { + try + { + if( _jsModule is not null ) + { + await _jsModule.DisposeAsync().ConfigureAwait( false ); + } + } + catch( Exception ex ) when( ex is JSDisconnectedException or OperationCanceledException ) + { + // The JSRuntime side may routinely be gone already if the reason we're disposing is that + // the client disconnected. This is not an error. + } + } } diff --git a/src/LumexUI/Components/Datebox/LumexDatebox.razor b/src/LumexUI/Components/Datebox/LumexDatebox.razor new file mode 100644 index 00000000..b8fdc683 --- /dev/null +++ b/src/LumexUI/Components/Datebox/LumexDatebox.razor @@ -0,0 +1,7 @@ +@namespace LumexUI +@inherits LumexInputFieldBase +@typeparam TValue + +@{ + base.BuildRenderTree(__builder); +} \ No newline at end of file diff --git a/src/LumexUI/Components/Datebox/LumexDatebox.razor.cs b/src/LumexUI/Components/Datebox/LumexDatebox.razor.cs new file mode 100644 index 00000000..c73045e7 --- /dev/null +++ b/src/LumexUI/Components/Datebox/LumexDatebox.razor.cs @@ -0,0 +1,120 @@ +// 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; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +using LumexUI.Extensions; + +using Microsoft.AspNetCore.Components; + +using InputDateType = LumexUI.Common.InputDateType; + +namespace LumexUI; + +/// +/// A component that represents an input field for entering date values. +/// +/// +/// The supported types for the date value are: +/// +/// +/// +/// +/// +/// +/// +public partial class LumexDatebox : LumexInputFieldBase +{ + private const string DateFormat = "yyyy-MM-dd"; // Compatible with HTML 'date' inputs + private const string DateTimeLocalFormat = "yyyy-MM-ddTHH:mm:ss"; // Compatible with HTML 'datetime-local' inputs + private const string MonthFormat = "yyyy-MM"; // Compatible with HTML 'month' inputs + private const string TimeFormat = "HH:mm:ss"; // Compatible with HTML 'time' inputs + + /// + /// Gets or sets the input type of the datebox. + /// + /// + /// The default value is + /// + [Parameter] + public InputDateType Type { get; set; } = InputDateType.Date; + + /// + protected override bool FilledOrFocused => true; + + private string _format = default!; + + /// + /// Initializes a new instance of the . + /// + public LumexDatebox() + { + var type = Nullable.GetUnderlyingType( typeof( TValue ) ) ?? typeof( TValue ); + + if( type != typeof( DateTime ) && + type != typeof( DateTimeOffset ) && + type != typeof( DateOnly ) && + type != typeof( TimeOnly ) ) + { + throw new InvalidOperationException( $"Unsupported {GetType()} type param '{type}'." ); + } + } + + /// + protected override void OnParametersSet() + { + SetInputType( Type.ToDescription() ); + + _format = Type switch + { + InputDateType.Date => DateFormat, + InputDateType.DateTimeLocal => DateTimeLocalFormat, + InputDateType.Month => MonthFormat, + InputDateType.Time => TimeFormat, + _ => throw new InvalidOperationException( $"Unsupported {nameof( InputDateType )} '{Type}'." ) + }; + } + + /// + protected override Task OnInputAsync( ChangeEventArgs args ) + { + return Task.CompletedTask; + } + + /// + protected override Task OnChangeAsync( ChangeEventArgs args ) + { + return SetCurrentValueAsStringAsync( (string?)args.Value ); + } + + /// + [ExcludeFromCodeCoverage] + protected override string FormatValueAsString( TValue? value ) + { + return value switch + { + DateTime dateTimeValue => BindConverter.FormatValue( dateTimeValue, _format, CultureInfo.InvariantCulture ), + DateTimeOffset dateTimeOffsetValue => BindConverter.FormatValue( dateTimeOffsetValue, _format, CultureInfo.InvariantCulture ), + DateOnly dateOnlyValue => BindConverter.FormatValue( dateOnlyValue, _format, CultureInfo.InvariantCulture ), + TimeOnly timeOnlyValue => BindConverter.FormatValue( timeOnlyValue, _format, CultureInfo.InvariantCulture ), + _ => string.Empty, // Handles null for Nullable, etc. + }; + } + + /// + protected override bool TryParseValueFromString( string? value, [MaybeNullWhen( false )] out TValue result ) + { + if( BindConverter.TryConvertTo( value, CultureInfo.InvariantCulture, out result ) ) + { + Debug.Assert( result != null ); + return true; + } + else + { + return false; + } + } +} \ No newline at end of file diff --git a/src/LumexUI/Components/Numbox/LumexNumbox.razor b/src/LumexUI/Components/Numbox/LumexNumbox.razor index 230d50d1..07a164d2 100644 --- a/src/LumexUI/Components/Numbox/LumexNumbox.razor +++ b/src/LumexUI/Components/Numbox/LumexNumbox.razor @@ -1,5 +1,5 @@ @namespace LumexUI -@inherits LumexInputFieldBase +@inherits LumexDebouncedInputBase @typeparam TValue @{ diff --git a/src/LumexUI/Components/Numbox/LumexNumbox.razor.cs b/src/LumexUI/Components/Numbox/LumexNumbox.razor.cs index 2d66e8f8..b04d6d45 100644 --- a/src/LumexUI/Components/Numbox/LumexNumbox.razor.cs +++ b/src/LumexUI/Components/Numbox/LumexNumbox.razor.cs @@ -15,7 +15,7 @@ namespace LumexUI; /// /// A component representing an input field for entering/editing numeric types. /// -public partial class LumexNumbox : LumexInputFieldBase +public partial class LumexNumbox : LumexDebouncedInputBase { private static readonly string _stepAttributeValue = GetStepAttributeValue(); diff --git a/src/LumexUI/Components/Textbox/LumexTextbox.razor b/src/LumexUI/Components/Textbox/LumexTextbox.razor index ee409450..f0ccd674 100644 --- a/src/LumexUI/Components/Textbox/LumexTextbox.razor +++ b/src/LumexUI/Components/Textbox/LumexTextbox.razor @@ -1,5 +1,5 @@ @namespace LumexUI -@inherits LumexInputFieldBase +@inherits LumexDebouncedInputBase @{ base.BuildRenderTree( __builder ); diff --git a/src/LumexUI/Components/Textbox/LumexTextbox.razor.cs b/src/LumexUI/Components/Textbox/LumexTextbox.razor.cs index 4a384bab..38f03962 100644 --- a/src/LumexUI/Components/Textbox/LumexTextbox.razor.cs +++ b/src/LumexUI/Components/Textbox/LumexTextbox.razor.cs @@ -12,7 +12,7 @@ namespace LumexUI; /// /// A component that represents an input field for entering values. /// -public partial class LumexTextbox : LumexInputFieldBase +public partial class LumexTextbox : LumexDebouncedInputBase { /// /// Gets or sets the input type of the textbox. diff --git a/tests/LumexUI.Tests/Components/Bases/InputBaseTests.cs b/tests/LumexUI.Tests/Components/Bases/InputBaseTests.cs index 77de8cac..b85d40ac 100644 --- a/tests/LumexUI.Tests/Components/Bases/InputBaseTests.cs +++ b/tests/LumexUI.Tests/Components/Bases/InputBaseTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) LumexUI 2024 +// 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 @@ -81,7 +81,7 @@ public async Task InputBase_WithValue_ShouldSupplyCurrentValueAsString() public void InputBase_WithValue_ShouldSupplyCurrentValueAsStringWithFormatting() { var model = new TestModel(); - var cut = RenderComponent( p => p + var cut = RenderComponent( p => p .Add( p => p.Value, new DateTime( 1915, 3, 2 ) ) .Add( p => p.ValueExpression, () => model.DateProperty ) ); @@ -93,7 +93,7 @@ public void InputBase_WithValue_ShouldSupplyCurrentValueAsStringWithFormatting() public async Task InputBase_WhenChangedValid_ShouldParseCurrentValueAsString() { var model = new TestModel(); - var cut = RenderComponent( p => p + var cut = RenderComponent( p => p .Add( p => p.Value, new DateTime( 1915, 3, 2 ) ) .Add( p => p.ValueExpression, () => model.DateProperty ) ); @@ -110,7 +110,7 @@ public async Task InputBase_WhenChangedValid_ShouldParseCurrentValueAsString() public async Task InputBase_WhenChangedInvalid_ShouldNotParseCurrentValueAsString() { var model = new TestModel(); - var cut = RenderComponent( p => p + var cut = RenderComponent( p => p .Add( p => p.Value, new DateTime( 1915, 3, 2 ) ) .Add( p => p.ValueExpression, () => model.DateProperty ) ); @@ -197,7 +197,7 @@ protected override bool TryParseValueFromString( string? value, out T result ) } } - private class TestDateInputComponent : TestInputComponent + private class TestDateboxComponent : TestInputComponent { protected override string FormatValueAsString( DateTime value ) => value.ToString( "yyyy/MM/dd", CultureInfo.InvariantCulture ); diff --git a/tests/LumexUI.Tests/Components/Datebox/DateboxTests.razor b/tests/LumexUI.Tests/Components/Datebox/DateboxTests.razor new file mode 100644 index 00000000..f0a4190c --- /dev/null +++ b/tests/LumexUI.Tests/Components/Datebox/DateboxTests.razor @@ -0,0 +1,223 @@ +@namespace LumexUI.Tests.Components +@inherits TestContext + +@using Microsoft.AspNetCore.Components.Web + +@code { + public DateboxTests() + { + Services.AddSingleton(); + + var module = JSInterop.SetupModule("./_content/LumexUI/js/components/input.js"); + module.Setup("input.getValidationMessage", _ => true); + } + + [Fact] + public void ShouldRenderCorrectly() + { + var action = () => Render( + @ + ); + + action.Should().NotThrow(); + } + + [Fact] + public void ShouldThrowIfNotDateType() + { + var action = () => Render( + @ + ); + + action.Should().Throw(); + } + + [Theory] + [InlineData(InputDateType.Date, "date")] + [InlineData(InputDateType.DateTimeLocal, "datetime-local")] + [InlineData(InputDateType.Month, "month")] + [InlineData(InputDateType.Time, "time")] + public void ShouldHaveCorrectTypeAttribute(InputDateType type, string actual) + { + var cut = Render( + @ + ); + + var input = cut.Find("input"); + + input.GetAttribute("type").Should().Be(actual); + } + + [Fact] + public void ShouldRenderMainWrapperWhenLabelOutside() + { + var cut = Render( + @ + ); + + cut.Find("[data-slot=main-wrapper]").Should().NotBeNull(); + } + + [Fact] + public void ShouldRenderHelperWrapperWhenDescriptionProvided() + { + var cut = Render( + @ + ); + + cut.Find("[data-slot=helper-wrapper]").Should().NotBeNull(); + cut.Find("[data-slot=description]").Should().NotBeNull(); + } + + [Fact] + public void ShouldRenderHelperWrapperWhenErrorMessageProvided() + { + var cut = Render( + @ + ); + var action = () => cut.Find("[data-slot=error-message]"); + + cut.Find("[data-slot=helper-wrapper]").Should().NotBeNull(); + + action.Should().Throw(because: "Error message should be rendered when state is invalid."); + } + + [Fact] + public void ShouldRenderErrorMessageWhenInvalid() + { + var cut = Render( + @ + ); + + cut.Find("[data-slot=error-message]").Should().NotBeNull(); + } + + [Fact] + public void ShouldHaveDisabledAttributeWhenDisabled() + { + var cut = Render( + @ + ); + + var input = cut.Find("input"); + + input.HasAttribute("disabled").Should().BeTrue(); + } + + [Fact] + public void ShouldRenderClearButtonWhenClearableAndHasValue() + { + var cut = Render( + @ + ); + + var clearButton = cut.Find("[role=button]"); + + clearButton.Should().NotBeNull(); + } + + [Fact] + public void ShouldClearValueOnClickWhenClearable() + { + DateTime? value = DateTime.Today; + var cut = Render>( + @ + ); + + var clearButton = cut.Find("[role=button]"); + clearButton.Click(); + + cut.Instance.Value.Should().BeNull(); + } + + [Theory] + [InlineData("Enter")] + [InlineData("Space")] + public void ShouldClearValueOnlyWithEnterOrSpaceWhenClearable(string code) + { + DateTime? value = DateTime.Today; + var cut = Render>( + @ + ); + + var clearButton = cut.Find("[role=button]"); + + clearButton.KeyUp(new KeyboardEventArgs() { Code = "Esc" }); + cut.Instance.Value.Should().Be(value); + + clearButton.KeyUp(new KeyboardEventArgs() { Code = code }); + cut.Instance.Value.Should().BeNull(); + } + + [Fact] + public void ShouldTriggerOnClearedCallbackOnClear() + { + bool isCleared = false; + var cut = Render>( + @ + ); + + var clearButton = cut.Find("[role=button]"); + clearButton.Click(); + + isCleared.Should().BeTrue(); + } + + [Fact] + public void ShouldFocusInputOnInputWrapperClick() + { + var cut = Render( + @ + ); + + var baseWrapper = cut.Find("[data-slot=base]"); + var inputWrapper = cut.Find("[data-slot=input-wrapper]"); + inputWrapper.Click(); + + baseWrapper.GetAttribute("data-focus").Should().Be("true", because: "Internal `Focused` flag is true."); + } + + [Theory] + [InlineData(true, false)] + [InlineData(false, true)] + public void ShouldNotFocusInputWhenDisabledOrReadonly(bool disabled, bool @readonly) + { + var cut = Render( + @ + ); + + var baseWrapper = cut.Find("[data-slot=base]"); + var inputWrapper = cut.Find("[data-slot=input-wrapper]"); + inputWrapper.Click(); + + baseWrapper.GetAttribute("data-focus").Should().Be("false", because: "Internal `Focused` flag is false."); + } + + [Fact] + public void ShouldChangeValueUsingChangeEvent() + { + var value = DateTime.Today; + var cut = Render>( + @ + ); + + var input = cut.Find("input"); + input.Change(value.ToString("yyyy-MM-dd")); + + cut.Instance.Value.Should().Be(value.Date); + } + + [Fact] + public void ShouldNotChangeValueUsingInputEvent() + { + var value = DateTime.Today; + var cut = Render>( + @ + ); + + var input = cut.Find("input"); + input.Input(value.ToString("yyyy-MM-dd")); + + cut.Instance.Value.Should().BeNull(because: "No-op"); + } +} \ No newline at end of file