diff --git a/docs/LumexUI.Docs.Client/Common/Navigation/NavigationStore.cs b/docs/LumexUI.Docs.Client/Common/Navigation/NavigationStore.cs index 6f289514..5c46617c 100644 --- a/docs/LumexUI.Docs.Client/Common/Navigation/NavigationStore.cs +++ b/docs/LumexUI.Docs.Client/Common/Navigation/NavigationStore.cs @@ -43,6 +43,7 @@ public class NavigationStore .Add( new( nameof( LumexNavbar ) ) ) .Add( new( nameof( LumexNumbox ) ) ) .Add( new( nameof( LumexPopover ) ) ) + .Add( new( nameof( LumexProgress ), PageStatus.New ) ) .Add( new( nameof( LumexRadioGroup ) ) ) .Add( new( nameof( LumexSelect ) ) ) .Add( new( nameof( LumexSkeleton ) ) ) @@ -100,6 +101,7 @@ public class NavigationStore .Add( new( nameof( LumexPopover ) ) ) .Add( new( nameof( LumexPopoverContent ) ) ) .Add( new( nameof( LumexPopoverTrigger ) ) ) + .Add( new( nameof( LumexProgress ) ) ) .Add( new( nameof( LumexRadio ) ) ) .Add( new( nameof( LumexRadioGroup ) ) ) .Add( new( nameof( LumexSelect ) ) ) diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Progress/Examples/Colors.razor b/docs/LumexUI.Docs.Client/Pages/Components/Progress/Examples/Colors.razor new file mode 100644 index 00000000..a4b71d26 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Progress/Examples/Colors.razor @@ -0,0 +1,9 @@ +
+ + + + + + + +
\ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Progress/Examples/CustomStyles.razor b/docs/LumexUI.Docs.Client/Pages/Components/Progress/Examples/CustomStyles.razor new file mode 100644 index 00000000..a5e576f3 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Progress/Examples/CustomStyles.razor @@ -0,0 +1,13 @@ +
+
+ + +
+
\ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Progress/Examples/Indeterminate.razor b/docs/LumexUI.Docs.Client/Pages/Components/Progress/Examples/Indeterminate.razor new file mode 100644 index 00000000..d2af19e1 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Progress/Examples/Indeterminate.razor @@ -0,0 +1,3 @@ +
+ +
\ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Progress/Examples/Label.razor b/docs/LumexUI.Docs.Client/Pages/Components/Progress/Examples/Label.razor new file mode 100644 index 00000000..68d3878e --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Progress/Examples/Label.razor @@ -0,0 +1,3 @@ +
+ +
\ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Progress/Examples/Sizes.razor b/docs/LumexUI.Docs.Client/Pages/Components/Progress/Examples/Sizes.razor new file mode 100644 index 00000000..266e8e3a --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Progress/Examples/Sizes.razor @@ -0,0 +1,5 @@ +
+ + + +
\ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Progress/Examples/Striped.razor b/docs/LumexUI.Docs.Client/Pages/Components/Progress/Examples/Striped.razor new file mode 100644 index 00000000..f2acd10d --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Progress/Examples/Striped.razor @@ -0,0 +1,9 @@ +
+ + + + + + + +
\ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Progress/Examples/Usage.razor b/docs/LumexUI.Docs.Client/Pages/Components/Progress/Examples/Usage.razor new file mode 100644 index 00000000..5987e7e4 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Progress/Examples/Usage.razor @@ -0,0 +1,3 @@ +
+ +
\ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Progress/Examples/Value.razor b/docs/LumexUI.Docs.Client/Pages/Components/Progress/Examples/Value.razor new file mode 100644 index 00000000..b4358881 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Progress/Examples/Value.razor @@ -0,0 +1,37 @@ +@implements IAsyncDisposable +@inject IJSRuntime JS + +
+ + + +
+ +@code { + private double value = 0; + private Timer? timer; + + protected override void OnInitialized() + { + timer = new Timer(_ => + { + value = value >= 100 ? 0 : value + 10; + InvokeAsync(StateHasChanged); + }, null, TimeSpan.Zero, TimeSpan.FromMilliseconds(500)); + } + + async ValueTask IAsyncDisposable.DisposeAsync() + { + if (timer is not null) + { + await timer.DisposeAsync(); + } + } +} \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Progress/PreviewCodes/Colors.razor b/docs/LumexUI.Docs.Client/Pages/Components/Progress/PreviewCodes/Colors.razor new file mode 100644 index 00000000..3965e774 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Progress/PreviewCodes/Colors.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Progress/PreviewCodes/CustomStyles.razor b/docs/LumexUI.Docs.Client/Pages/Components/Progress/PreviewCodes/CustomStyles.razor new file mode 100644 index 00000000..91b1668b --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Progress/PreviewCodes/CustomStyles.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Progress/PreviewCodes/Indeterminate.razor b/docs/LumexUI.Docs.Client/Pages/Components/Progress/PreviewCodes/Indeterminate.razor new file mode 100644 index 00000000..95d7a5b2 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Progress/PreviewCodes/Indeterminate.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Progress/PreviewCodes/Label.razor b/docs/LumexUI.Docs.Client/Pages/Components/Progress/PreviewCodes/Label.razor new file mode 100644 index 00000000..6688e240 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Progress/PreviewCodes/Label.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Progress/PreviewCodes/Sizes.razor b/docs/LumexUI.Docs.Client/Pages/Components/Progress/PreviewCodes/Sizes.razor new file mode 100644 index 00000000..c30ffb41 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Progress/PreviewCodes/Sizes.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Progress/PreviewCodes/Striped.razor b/docs/LumexUI.Docs.Client/Pages/Components/Progress/PreviewCodes/Striped.razor new file mode 100644 index 00000000..f7f1e96f --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Progress/PreviewCodes/Striped.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Progress/PreviewCodes/Usage.razor b/docs/LumexUI.Docs.Client/Pages/Components/Progress/PreviewCodes/Usage.razor new file mode 100644 index 00000000..57543ea8 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Progress/PreviewCodes/Usage.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Progress/PreviewCodes/Value.razor b/docs/LumexUI.Docs.Client/Pages/Components/Progress/PreviewCodes/Value.razor new file mode 100644 index 00000000..91df5ff7 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Progress/PreviewCodes/Value.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Progress/Progress.razor b/docs/LumexUI.Docs.Client/Pages/Components/Progress/Progress.razor new file mode 100644 index 00000000..fd502fd6 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Progress/Progress.razor @@ -0,0 +1,102 @@ +@page "/docs/components/progress" +@layout DocsContentLayout + +@using LumexUI.Docs.Client.Pages.Components.Progress.PreviewCodes + + + + + + + Note: Make sure to pass the AriaLabel prop when the Label prop is not provided. This is required for accessibility. + + + + +

Use the Size parameter to control the height of the progress bar.

+ +
+ + +

Use the Color parameter to set the color of the progress bar.

+ +
+ + +

+ You can use the Indeterminate prop to display an indeterminate progress bar. This is useful when you don't know how long an operation will take. +

+ +
+ + + + + + +

+ Use the Label parameter to display a custom label above the progress bar. +

+
+ + + + Note: If you pass the Label prop you don't need to pass AriaLabel prop anymore. + + + + +

+ Use the ShowValueLabel parameter to display the progress value/percentage. +

+ +
+ +
+ + +
+

ProgressBar

+
    +
  • Class: The CSS class names to style the progress bar track.
  • +
  • Classes: The CSS class names to style the progress bar slots.
  • +
+
+ +
+ + + +@code { + [CascadingParameter] private DocsContentLayout Layout { get; set; } = default!; + + private readonly Heading[] _headings = new Heading[] + { + new("Usage", [ + new("Sizes"), + new("Colors"), + new("Indeterminate"), + new("With Label"), + new("With Value"), + ]), + new("Custom Styles"), + new("API") + }; + + private readonly string[] _apiComponents = new string[] + { + nameof(LumexProgress) + }; + + protected override void OnInitialized() + { + Layout.Initialize( + title: "Progress", + category: "Components", + description: "Progress displays the progress of a task or operation, and can also be used as animated loading indicators.", + headings: _headings, + linksProps: new ComponentLinksProps("Progress", isServer: false) + ); + } +} diff --git a/src/LumexUI/Components/Progress/LumexProgress.razor b/src/LumexUI/Components/Progress/LumexProgress.razor new file mode 100644 index 00000000..ec78db6b --- /dev/null +++ b/src/LumexUI/Components/Progress/LumexProgress.razor @@ -0,0 +1,46 @@ +@namespace LumexUI +@inherits LumexComponentBase + +@using S = ProgressSlots + +
+ + @if( !string.IsNullOrEmpty( Label ) || ShowValueLabel ) + { +
+ @if( !string.IsNullOrEmpty( Label ) ) + { + + @Label + + } + + @if( ShowValueLabel && !Indeterminate ) + { + + @ValueText + + } +
+ } + +
+
+
+
+
diff --git a/src/LumexUI/Components/Progress/LumexProgress.razor.cs b/src/LumexUI/Components/Progress/LumexProgress.razor.cs new file mode 100644 index 00000000..9e9c3d96 --- /dev/null +++ b/src/LumexUI/Components/Progress/LumexProgress.razor.cs @@ -0,0 +1,264 @@ +// 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; +using System.Globalization; + +using Microsoft.AspNetCore.Components; + +namespace LumexUI; + +/// +/// A component representing a progress bar for displaying progress or loading states. +/// +public partial class LumexProgress : LumexComponentBase, ISlotComponent +{ + /// + /// Gets or sets the label to be rendered above the progress bar. + /// + [Parameter] public string? Label { get; set; } + + /// + /// Gets or sets the ARIA label for accessibility. If not provided, defaults to the Label or "Progress"/"Loading". + /// + /// + /// This is required for accessibility when the prop is not provided. + /// + [Parameter] public string? AriaLabel { get; set; } + + /// + /// Gets or sets custom content for the value label. + /// + [Parameter] public string? ValueLabel { get; set; } + + /// + /// Gets or sets the progress value (from to ). + /// + /// + /// The default value is 0. + /// + [Parameter] public double Value { get; set; } + + /// + /// Gets or sets the minimum value for the progress bar. + /// + /// + /// The default value is 0. + /// + [Parameter] public double MinValue { get; set; } + + /// + /// Gets or sets the maximum value for the progress bar. + /// + /// + /// The default value is 100. This determines the upper bound for the parameter. + /// + [Parameter] public double MaxValue { get; set; } = 100; + + /// + /// Gets or sets a value indicating whether the progress bar is in an indeterminate state. + /// + /// + /// When , the progress bar will display an animated loading indicator + /// instead of showing a specific progress value. This is useful when the duration of an operation is unknown. + /// + [Parameter] public bool Indeterminate { get; set; } + + /// + /// Gets or sets a value indicating whether to show the value label with the progress percentage. + /// + /// + /// The default value is . When , the value label is shown. + /// + [Parameter] public bool ShowValueLabel { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the progress bar is disabled. + /// + /// + /// The default value is . + /// + [Parameter] public bool Disabled { get; set; } + + /// + /// Gets or sets a value indicating whether the progress bar should have a striped appearance. + /// + /// + /// The default value is . + /// + [Parameter] public bool Striped { get; set; } + + /// + /// Gets or sets the color of the progress bar. + /// + /// + /// The default value is + /// + [Parameter] public ThemeColor Color { get; set; } = ThemeColor.Primary; + + /// + /// Gets or sets the size of the progress bar. + /// + /// + /// The default value is + /// + [Parameter] public Size Size { get; set; } = Size.Medium; + + /// + /// Gets or sets the border radius of the progress bar. + /// + /// + /// The default value is + /// + [Parameter] public Radius Radius { get; set; } = Radius.Full; + + /// + /// Gets or sets the CSS class names for the progress bar slots. + /// + [Parameter] public ProgressSlots? Classes { get; set; } + + private double ClampedValue => MaxValue > MinValue ? Math.Clamp( Value, MinValue, MaxValue ) : MinValue; + private double Percentage => MaxValue > MinValue ? ( ( ClampedValue - MinValue ) / ( MaxValue - MinValue ) ) * 100 : 0; + private string ValueText => !string.IsNullOrEmpty( ValueLabel ) ? ValueLabel : $"{Percentage:F0}%"; + + private Dictionary _slots = []; + + /// + protected override void OnParametersSet() + { + var progressBar = Styles.Progress.Style( TwMerge ); + _slots = progressBar( new() + { + [nameof( Size )] = Size.ToString(), + [nameof( Radius )] = Radius.ToString(), + [nameof( Color )] = Color.ToString(), + [nameof( Indeterminate )] = Indeterminate.ToString(), + [nameof( Striped )] = Striped.ToString(), + [nameof( Disabled )] = Disabled.ToString(), + } ); + + UpdateAdditionalAttributes(); + + } + + [ExcludeFromCodeCoverage] + private string? GetStyles( string slot ) + { + if( !_slots.TryGetValue( slot, out var styles ) ) + { + throw new NotImplementedException(); + } + + return slot switch + { + nameof( ProgressSlots.Base ) => styles( Classes?.Base, Class ), + nameof( ProgressSlots.LabelWrapper ) => styles( Classes?.LabelWrapper ), + nameof( ProgressSlots.Label ) => styles( Classes?.Label ), + nameof( ProgressSlots.Value ) => styles( Classes?.Value ), + nameof( ProgressSlots.Track ) => styles( Classes?.Track ), + nameof( ProgressSlots.Indicator ) => styles( Classes?.Indicator ), + _ => throw new NotImplementedException() + }; + } + + + private string? GetAriaValueNow() + { + if( Indeterminate ) + return null; + + return ClampedValue.ToString( "0.##", CultureInfo.InvariantCulture ); + } + + private string? GetAriaValueMin() + { + return MinValue.ToString( "0.##", CultureInfo.InvariantCulture ); + } + + private string? GetAriaValueMax() + { + return MaxValue.ToString( "0.##", CultureInfo.InvariantCulture ); + } + + private string? GetAriaValueText() + { + return Indeterminate ? ( string.IsNullOrWhiteSpace( ValueLabel ) ? "Loading" : ValueLabel ) : ValueText; + } + + private string GetTransformValue() + { + var width = Indeterminate ? 100 : Percentage; + var transformWith = 100 - width; + return String.Format("-{0}{1}", ( transformWith ).ToString( "0.##", CultureInfo.InvariantCulture ) ,"%"); + } + + private string GetIndicatorStyle() + { + var transform = $"transform: translateX({GetTransformValue()})"; + + if( Striped ) + { + var (color, lightColor) = Color switch + { + ThemeColor.Default => ("var(--color-default)", "var(--color-default-100)"), + ThemeColor.Primary => ("var(--color-primary)", "var(--color-primary-100)"), + ThemeColor.Secondary => ("var(--color-secondary)", "var(--color-secondary-100)"), + ThemeColor.Success => ("var(--color-success)", "var(--color-success-100)"), + ThemeColor.Warning => ("var(--color-warning)", "var(--color-warning-100)"), + ThemeColor.Danger => ("var(--color-danger)", "var(--color-danger-100)"), + ThemeColor.Info => ("var(--color-info)", "var(--color-info-100)"), + _ => ("currentColor", "rgba(255, 255, 255, 0.15)") + }; + + return $"{transform}; --stripe-color: {color}; --stripe-color-light: {lightColor};"; + } + + return transform; + } + + private void UpdateAdditionalAttributes() + { + var hasAriaLabel = AdditionalAttributes is not null && AdditionalAttributes.ContainsKey( "aria-label" ); + if( !hasAriaLabel ) + { + if( ConvertToDictionary( AdditionalAttributes, out var additionalAttributes ) ) + { + AdditionalAttributes = additionalAttributes; + } + + additionalAttributes["aria-label"] = Label ?? ( Indeterminate ? "Loading" : "Progress" ); + } + } + + [ExcludeFromCodeCoverage] + private static bool ConvertToDictionary( IReadOnlyDictionary? source, out Dictionary result ) + { + var newDictionaryCreated = true; + + if( source is null ) + { + result = []; + } + else if( source is Dictionary currentDictionary ) + { + result = currentDictionary; + newDictionaryCreated = false; + } + else + { + result = []; + + foreach( var item in source ) + { + result.Add( item.Key, item.Value ); + } + } + + return newDictionaryCreated; + } +} + diff --git a/src/LumexUI/Components/Progress/ProgressSlots.cs b/src/LumexUI/Components/Progress/ProgressSlots.cs new file mode 100644 index 00000000..32ed41a1 --- /dev/null +++ b/src/LumexUI/Components/Progress/ProgressSlots.cs @@ -0,0 +1,47 @@ +// 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; + +namespace LumexUI; + +/// +/// Represents the set of customizable slots for the component. +/// +[ExcludeFromCodeCoverage] +public class ProgressSlots : SlotBase +{ + /// + /// Gets or sets the CSS class for the base/root slot. + /// + public string? Base { get; set; } + + /// + /// Gets or sets the CSS class for the label wrapper slot. + /// + public string? LabelWrapper { get; set; } + + /// + /// Gets or sets the CSS class for the label slot. + /// + public string? Label { get; set; } + + /// + /// Gets or sets the CSS class for the value slot. + /// + public string? Value { get; set; } + + /// + /// Gets or sets the CSS class for the track slot. + /// + public string? Track { get; set; } + + /// + /// Gets or sets the CSS class for the indicator/fill slot. + /// + public string? Indicator { get; set; } + +} diff --git a/src/LumexUI/Styles/Progress.cs b/src/LumexUI/Styles/Progress.cs new file mode 100644 index 00000000..052e731d --- /dev/null +++ b/src/LumexUI/Styles/Progress.cs @@ -0,0 +1,223 @@ +// 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; + +using TailwindMerge; + +namespace LumexUI.Styles; + +[ExcludeFromCodeCoverage] +internal static class Progress +{ + private static ComponentVariant? _variant; + + public static ComponentVariant Style( TwMerge twMerge ) + { + var twVariants = new TwVariants( twMerge ); + + return _variant ??= twVariants.Create( new VariantConfig() + { + Slots = new SlotCollection + { + [nameof( ProgressSlots.Base )] = new ElementClass() + .Add( "flex" ) + .Add( "flex-col" ) + .Add( "gap-2" ) + .Add( "w-full" ), + + [nameof( ProgressSlots.LabelWrapper )] = new ElementClass() + .Add( "flex" ) + .Add( "justify-between" ), + + [nameof( ProgressSlots.Label )] = new ElementClass() + .Add( "text-small" ) + .Add( "font-medium" ) + .Add( "text-foreground" ), + + [nameof( ProgressSlots.Value )] = new ElementClass() + .Add( "text-small" ) + .Add( "font-medium" ) + .Add( "text-foreground" ), + + [nameof( ProgressSlots.Track )] = new ElementClass() + .Add( "relative" ) + .Add( "w-full" ) + .Add( "overflow-hidden" ) + .Add( "rounded-full" ) + .Add( "bg-default-200" ) + .Add( "dark:bg-default-100" ), + + [nameof( ProgressSlots.Indicator )] = new ElementClass() + .Add( "h-full" ) + .Add( "rounded-full" ) + .Add( "transition-all" ) + .Add( "duration-500" ) + .Add( "ease-in-out" ), + }, + + Variants = new VariantCollection + { + [nameof( LumexProgress.Size )] = new VariantValueCollection + { + [nameof( Size.Small )] = new SlotCollection + { + [nameof( ProgressSlots.Track )] = "h-1", + [nameof( ProgressSlots.Label )] = "text-tiny", + [nameof( ProgressSlots.Value )] = "text-tiny", + }, + [nameof( Size.Medium )] = new SlotCollection + { + [nameof( ProgressSlots.Track )] = "h-2", + [nameof( ProgressSlots.Label )] = "text-small", + [nameof( ProgressSlots.Value )] = "text-small", + }, + [nameof( Size.Large )] = new SlotCollection + { + [nameof( ProgressSlots.Track )] = "h-3", + [nameof( ProgressSlots.Label )] = "text-medium", + [nameof( ProgressSlots.Value )] = "text-medium", + }, + }, + + [nameof( LumexProgress.Radius )] = new VariantValueCollection + { + [nameof( Radius.None )] = new SlotCollection + { + [nameof( ProgressSlots.Track )] = "rounded-none", + }, + [nameof( Radius.Small )] = new SlotCollection + { + [nameof( ProgressSlots.Track )] = "rounded-small", + }, + [nameof( Radius.Medium )] = new SlotCollection + { + [nameof( ProgressSlots.Track )] = "rounded-medium", + }, + [nameof( Radius.Large )] = new SlotCollection + { + [nameof( ProgressSlots.Track )] = "rounded-large", + }, + [nameof( Radius.Full )] = new SlotCollection + { + [nameof( ProgressSlots.Track )] = "rounded-full", + }, + }, + + [nameof( LumexProgress.Indeterminate )] = new VariantValueCollection + { + [bool.TrueString] = new SlotCollection + { + [nameof( ProgressSlots.Indicator )] = "animate-progress-loading", + }, + }, + + [nameof( LumexProgress.Disabled )] = new VariantValueCollection + { + [bool.TrueString] = new SlotCollection + { + [nameof( ProgressSlots.Base )] = "opacity-50 cursor-not-allowed", + }, + }, + }, + + CompoundVariants = + [ + // Color variants for indicator + new CompoundVariant() + { + Conditions = new() + { + [nameof( LumexProgress.Color )] = nameof( ThemeColor.Default ) + }, + Classes = new SlotCollection() + { + [nameof( ProgressSlots.Indicator )] = ColorVariants.Solid[ThemeColor.Default] + } + }, + new CompoundVariant() + { + Conditions = new() + { + [nameof( LumexProgress.Color )] = nameof( ThemeColor.Primary ) + }, + Classes = new SlotCollection() + { + [nameof( ProgressSlots.Indicator )] = ColorVariants.Solid[ThemeColor.Primary] + } + }, + new CompoundVariant() + { + Conditions = new() + { + [nameof( LumexProgress.Color )] = nameof( ThemeColor.Secondary ) + }, + Classes = new SlotCollection() + { + [nameof( ProgressSlots.Indicator )] = ColorVariants.Solid[ThemeColor.Secondary] + } + }, + new CompoundVariant() + { + Conditions = new() + { + [nameof( LumexProgress.Color )] = nameof( ThemeColor.Success ) + }, + Classes = new SlotCollection() + { + [nameof( ProgressSlots.Indicator )] = ColorVariants.Solid[ThemeColor.Success] + } + }, + new CompoundVariant() + { + Conditions = new() + { + [nameof( LumexProgress.Color )] = nameof( ThemeColor.Warning ) + }, + Classes = new SlotCollection() + { + [nameof( ProgressSlots.Indicator )] = ColorVariants.Solid[ThemeColor.Warning] + } + }, + new CompoundVariant() + { + Conditions = new() + { + [nameof( LumexProgress.Color )] = nameof( ThemeColor.Danger ) + }, + Classes = new SlotCollection() + { + [nameof( ProgressSlots.Indicator )] = ColorVariants.Solid[ThemeColor.Danger] + } + }, + new CompoundVariant() + { + Conditions = new() + { + [nameof( LumexProgress.Color )] = nameof( ThemeColor.Info ) + }, + Classes = new SlotCollection() + { + [nameof( ProgressSlots.Indicator )] = ColorVariants.Solid[ThemeColor.Info] + } + }, + new CompoundVariant() + { + Conditions = new() + { + [nameof( LumexProgress.Striped )] = bool.TrueString + }, + Classes = new SlotCollection() + { + [nameof( ProgressSlots.Indicator )] = "bg-stripe-gradient", + } + } + ] + } ); + } +} + diff --git a/src/LumexUI/Styles/_theme.css b/src/LumexUI/Styles/_theme.css index 714a0d18..b07fc17a 100644 --- a/src/LumexUI/Styles/_theme.css +++ b/src/LumexUI/Styles/_theme.css @@ -177,6 +177,13 @@ --animate-sway: sway 0.75s infinite; --animate-blink: blink 1.5s infinite both; --animate-fade-out: fade-out 1.2s infinite; + --animate-progress-loading: progress-loading 1.5s ease-in-out infinite; + + /* Custom stripe gradient variables */ + --stripe-angle: 45deg; + --stripe-color: currentColor; + --stripe-color-light: rgba(255, 255, 255, 0.15); + --stripe-width: 10px; @keyframes enter { 0% { @@ -226,6 +233,16 @@ } } + @keyframes progress-loading { + 0% { + transform: translateX(-100%); + } + + 100% { + transform: translateX(400%); + } + } + /* Override Defaults */ --default-transition-duration: 250ms; } @@ -248,3 +265,18 @@ display: none; } } + +/* + Custom striped gradient utility for progress bars + Uses CSS variables: --stripe-color, --stripe-angle, --stripe-width + Set these variables inline to customize stripes +*/ +@utility bg-stripe-gradient { + background: repeating-linear-gradient( + var(--stripe-angle, 45deg), + var(--stripe-color, currentColor), + var(--stripe-color, currentColor) var(--stripe-width, 10px), + var(--stripe-color-light, rgba(255, 255, 255, 0.15)) var(--stripe-width, 10px), + var(--stripe-color-light, rgba(255, 255, 255, 0.15)) calc(var(--stripe-width, 10px) * 2) + ); +} diff --git a/tests/LumexUI.Tests/Components/Progress/ProgressTests.cs b/tests/LumexUI.Tests/Components/Progress/ProgressTests.cs new file mode 100644 index 00000000..29099096 --- /dev/null +++ b/tests/LumexUI.Tests/Components/Progress/ProgressTests.cs @@ -0,0 +1,329 @@ +// 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.Tests.Extensions; + +using Microsoft.Extensions.DependencyInjection; + +using TailwindMerge; + +namespace LumexUI.Tests.Components; + +public class ProgressTests : TestContext +{ + public ProgressTests() + { + Services.AddSingleton(); + } + + [Fact] + public void Progress_ShouldRenderCorrectly() + { + var action = () => RenderComponent(); + + action.Should().NotThrow(); + } + + [Fact] + public void Progress_ShouldHaveTrackSlot() + { + var cut = RenderComponent(); + + cut.FindBySlot( "track" ).Should().NotBeNull(); + } + + [Fact] + public void Progress_ShouldHaveIndicatorSlot() + { + var cut = RenderComponent(); + + cut.FindBySlot( "indicator" ).Should().NotBeNull(); + } + + [Fact] + public void Progress_WithValue_ShouldSetCorrectIndicatorWidth() + { + var cut = RenderComponent( p => p + .Add( p => p.Value, 50 ) + .Add( p => p.MaxValue, 100 ) + ); + + var indicator = cut.FindBySlot( "indicator" ); + var style = indicator.GetAttribute( "style" ); + + style.Should().Contain( "width: 50.00%" ); + } + + [Fact] + public void Progress_WithCustomMinAndMaxValue_ShouldCalculateCorrectPercentage() + { + var cut = RenderComponent( p => p + .Add( p => p.Value, 75 ) + .Add( p => p.MinValue, 50 ) + .Add( p => p.MaxValue, 150 ) + ); + + var indicator = cut.FindBySlot( "indicator" ); + var style = indicator.GetAttribute( "style" ); + + style.Should().Contain( "width: 25.00%" ); + } + + [Fact] + public void Progress_WithValueAboveMaxValue_ShouldClampToMaxValue() + { + var cut = RenderComponent( p => p + .Add( p => p.Value, 150 ) + .Add( p => p.MaxValue, 100 ) + ); + + var indicator = cut.FindBySlot( "indicator" ); + var style = indicator.GetAttribute( "style" ); + + style.Should().Contain( "width: 100.00%" ); + } + + [Fact] + public void Progress_WithValueBelowMinValue_ShouldClampToMinValue() + { + var cut = RenderComponent( p => p + .Add( p => p.Value, -10 ) + .Add( p => p.MinValue, 0 ) + .Add( p => p.MaxValue, 100 ) + ); + + var indicator = cut.FindBySlot( "indicator" ); + var style = indicator.GetAttribute( "style" ); + + style.Should().Contain( "width: 0.00%" ); + } + + [Fact] + public void Progress_Indeterminate_ShouldSetIndicatorWidthTo100Percent() + { + var cut = RenderComponent( p => p + .Add( p => p.Indeterminate, true ) + .Add( p => p.Value, 50 ) + ); + + var indicator = cut.FindBySlot( "indicator" ); + var style = indicator.GetAttribute( "style" ); + + style.Should().Contain( "width: 100%" ); + } + + [Fact] + public void Progress_Indeterminate_ShouldHaveAnimateProgressLoading() + { + var cut = RenderComponent( p => p + .Add( p => p.Indeterminate, true ) + ); + + var indicator = cut.FindBySlot( "indicator" ); + + indicator.ClassList.Should().Contain( "animate-progress-loading" ); + } + + [Fact] + public void Progress_ShowValueLabel_ShouldRenderValueLabel() + { + var cut = RenderComponent( p => p + .Add( p => p.ShowValueLabel, true ) + .Add( p => p.Value, 50 ) + ); + + cut.FindBySlot( "value" ).Should().NotBeNull(); + } + + [Fact] + public void Progress_ShowValueLabelFalse_ShouldNotRenderValueLabel() + { + var cut = RenderComponent( p => p + .Add( p => p.ShowValueLabel, false ) + .Add( p => p.Value, 50 ) + ); + + cut.FindBySlot( "value" ).Should().BeNull(); + } + + [Fact] + public void Progress_Indeterminate_ShouldNotShowValueLabel() + { + var cut = RenderComponent( p => p + .Add( p => p.Indeterminate, true ) + .Add( p => p.ShowValueLabel, true ) + ); + + cut.FindBySlot( "value" ).Should().BeNull(); + } + + [Fact] + public void Progress_WithLabel_ShouldRenderLabel() + { + var cut = RenderComponent( p => p + .Add( p => p.Label, "Downloading..." ) + .Add( p => p.Value, 50 ) + ); + + cut.FindBySlot( "label" ).Should().NotBeNull(); + cut.FindBySlot( "label" ).TextContent.Should().Contain( "Downloading..." ); + } + + [Fact] + public void Progress_WithoutLabel_ShouldNotRenderLabel() + { + var cut = RenderComponent( p => p + .Add( p => p.Value, 50 ) + ); + + cut.FindBySlot( "label" ).Should().BeNull(); + } + + [Fact] + public void Progress_DefaultShowValueLabel_ShouldNotDisplayValueLabelByDefault() + { + var cut = RenderComponent( p => p + .Add( p => p.Value, 75 ) + ); + + var value = cut.FindBySlot( "value" ); + value.Should().BeNull(); + } + + [Fact] + public void Progress_WithCustomValueLabel_ShouldDisplayCustomValue() + { + var cut = RenderComponent( p => p + .Add( p => p.Value, 50 ) + .Add( p => p.ShowValueLabel, true ) + .Add( p => p.ValueLabel, "50/100" ) + ); + + var value = cut.FindBySlot( "value" ); + value.TextContent.Should().Contain( "50/100" ); + } + + [Theory] + [InlineData( ThemeColor.Default )] + [InlineData( ThemeColor.Primary )] + [InlineData( ThemeColor.Secondary )] + [InlineData( ThemeColor.Success )] + [InlineData( ThemeColor.Warning )] + [InlineData( ThemeColor.Danger )] + [InlineData( ThemeColor.Info )] + public void Progress_Color_ShouldApplyCorrectColor( ThemeColor color ) + { + var action = () => RenderComponent( p => p + .Add( p => p.Color, color ) + ); + + action.Should().NotThrow(); + } + + [Theory] + [InlineData( Size.Small )] + [InlineData( Size.Medium )] + [InlineData( Size.Large )] + public void Progress_Size_ShouldApplyCorrectSize( Size size ) + { + var action = () => RenderComponent( p => p + .Add( p => p.Size, size ) + ); + + action.Should().NotThrow(); + } + + [Theory] + [InlineData( Radius.None )] + [InlineData( Radius.Small )] + [InlineData( Radius.Medium )] + [InlineData( Radius.Large )] + [InlineData( Radius.Full )] + public void Progress_Radius_ShouldApplyCorrectRadius( Radius radius ) + { + var action = () => RenderComponent( p => p + .Add( p => p.Radius, radius ) + ); + + action.Should().NotThrow(); + } + + [Fact] + public void Progress_ShouldHaveCorrectAriaAttributes() + { + var cut = RenderComponent( p => p + .Add( p => p.Value, 50 ) + .Add( p => p.MaxValue, 100 ) + ); + + var base_ = cut.FindBySlot( "base" ); + base_.GetAttribute( "role" ).Should().Be( "progressbar" ); + base_.GetAttribute( "aria-valuenow" ).Should().Be( "50" ); + base_.GetAttribute( "aria-valuemin" ).Should().Be( "0" ); + base_.GetAttribute( "aria-valuemax" ).Should().Be( "100" ); + } + + [Fact] + public void Progress_Indeterminate_ShouldHaveNullAriaValuenow() + { + var cut = RenderComponent( p => p + .Add( p => p.Indeterminate, true ) + ); + + var base_ = cut.FindBySlot( "base" ); + base_.HasAttribute( "aria-valuenow" ).Should().BeFalse(); + base_.GetAttribute( "aria-label" ).Should().Be( "Loading" ); + } + + [Fact] + public void Progress_WithZeroMaxValue_ShouldHandleGracefully() + { + var cut = RenderComponent( p => p + .Add( p => p.Value, 50 ) + .Add( p => p.MaxValue, 0 ) + ); + + var indicator = cut.FindBySlot( "indicator" ); + var style = indicator.GetAttribute( "style" ); + + style.Should().Contain( "width: 0.00%" ); + } + + [Fact] + public void Progress_WithCustomClasses_ShouldApplyClasses() + { + var cut = RenderComponent( p => p + .Add( p => p.Class, "custom-class" ) + ); + + var base_ = cut.FindBySlot( "base" ); + base_.ClassList.Should().Contain( "custom-class" ); + } + + [Fact] + public void Progress_WithCustomSlots_ShouldApplySlotClasses() + { + var slots = new ProgressSlots + { + Track = "custom-track", + Indicator = "custom-indicator", + Label = "custom-label" + }; + + var cut = RenderComponent( p => p + .Add( p => p.Label, "Testing..." ) + .Add( p => p.Value, 50 ) + .Add( p => p.Classes, slots ) + ); + + var track = cut.FindBySlot( "track" ); + var indicator = cut.FindBySlot( "indicator" ); + var label = cut.FindBySlot( "label" ); + + track.ClassList.Should().Contain( "custom-track" ); + indicator.ClassList.Should().Contain( "custom-indicator" ); + label.ClassList.Should().Contain( "custom-label" ); + } +}