diff --git a/docs/LumexUI.Docs.Client/Common/Navigation/NavigationStore.cs b/docs/LumexUI.Docs.Client/Common/Navigation/NavigationStore.cs index 28f9a91c..b83b4f3f 100644 --- a/docs/LumexUI.Docs.Client/Common/Navigation/NavigationStore.cs +++ b/docs/LumexUI.Docs.Client/Common/Navigation/NavigationStore.cs @@ -40,6 +40,7 @@ public class NavigationStore .Add( new( nameof( LumexPopover ) ) ) .Add( new( nameof( LumexRadioGroup ) ) ) .Add( new( nameof( LumexSelect ) ) ) + .Add( new( nameof( LumexSkeleton ), ComponentStatus.New ) ) .Add( new( nameof( LumexSwitch ) ) ) .Add( new( nameof( LumexTabs ) ) ) .Add( new( nameof( LumexTextbox ) ) ); @@ -49,6 +50,7 @@ public class NavigationStore .Add( new( nameof( LumexAccordion ) ) ) .Add( new( nameof( LumexAccordionItem ) ) ) .Add( new( nameof( LumexAvatar ) ) ) + .Add( new( nameof( LumexAvatarGroup ) ) ) //.Add( nameof( LumexBooleanInputBase ) ) .Add( new( nameof( LumexButton ) ) ) .Add( new( nameof( LumexCard ) ) ) @@ -84,6 +86,7 @@ public class NavigationStore .Add( new( nameof( LumexPopoverTrigger ) ) ) .Add( new( nameof( LumexSelect ) ) ) .Add( new( nameof( LumexSelectItem ) ) ) + .Add( new( nameof( LumexSkeleton ) ) ) .Add( new( nameof( LumexSwitch ) ) ) .Add( new( nameof( LumexTab ) ) ) .Add( new( nameof( LumexTabs ) ) ) diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Skeleton/Examples/Loading.razor b/docs/LumexUI.Docs.Client/Pages/Components/Skeleton/Examples/Loading.razor new file mode 100644 index 00000000..17ef3129 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Skeleton/Examples/Loading.razor @@ -0,0 +1,29 @@ +
+ + +
+ + +
+ +
+ + +
+ + +
+ +
+ + + @(_loading ? "Hide" : "Show") Skeleton + +
+ +@code { + private bool _loading = true; +} \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Skeleton/Examples/Standalone.razor b/docs/LumexUI.Docs.Client/Pages/Components/Skeleton/Examples/Standalone.razor new file mode 100644 index 00000000..289feef9 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Skeleton/Examples/Standalone.razor @@ -0,0 +1,9 @@ +
+
+ +
+
+ + +
+
\ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Skeleton/Examples/Usage.razor b/docs/LumexUI.Docs.Client/Pages/Components/Skeleton/Examples/Usage.razor new file mode 100644 index 00000000..d3340528 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Skeleton/Examples/Usage.razor @@ -0,0 +1,16 @@ + + +
+ +
+ +
+ + +
+ + +
+ +
+ \ No newline at end of file diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Skeleton/PreviewCodes/Loading.razor b/docs/LumexUI.Docs.Client/Pages/Components/Skeleton/PreviewCodes/Loading.razor new file mode 100644 index 00000000..49e1e2d2 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Skeleton/PreviewCodes/Loading.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Skeleton/PreviewCodes/Standalone.razor b/docs/LumexUI.Docs.Client/Pages/Components/Skeleton/PreviewCodes/Standalone.razor new file mode 100644 index 00000000..948a1ba3 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Skeleton/PreviewCodes/Standalone.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Skeleton/PreviewCodes/Usage.razor b/docs/LumexUI.Docs.Client/Pages/Components/Skeleton/PreviewCodes/Usage.razor new file mode 100644 index 00000000..1568437f --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Skeleton/PreviewCodes/Usage.razor @@ -0,0 +1,5 @@ +@rendermode InteractiveWebAssembly + + + + diff --git a/docs/LumexUI.Docs.Client/Pages/Components/Skeleton/Skeleton.razor b/docs/LumexUI.Docs.Client/Pages/Components/Skeleton/Skeleton.razor new file mode 100644 index 00000000..2c110369 --- /dev/null +++ b/docs/LumexUI.Docs.Client/Pages/Components/Skeleton/Skeleton.razor @@ -0,0 +1,76 @@ +@page "/docs/components/skeleton" +@layout DocsContentLayout + +@using LumexUI.Docs.Client.Pages.Components.Skeleton.PreviewCodes + + +

+ The skeleton component wraps content, providing a + loading placeholder until the actual content is ready. +

+ + + +

+ The skeleton can also be used as a standalone placeholder to simulate + loading states for specific UI elements like text, images, or buttons. +

+ +
+ + +

You can toggle the skeleton visibility dynamically based on a loading state.

+ +
+
+ + +
+

Skeleton

+
    +
  • Class: The CSS class names to style the wrapper.
  • +
  • Classes: The CSS class names to style the slots.
  • +
+
+
+ + + +@code { + [CascadingParameter] private DocsContentLayout Layout { get; set; } = default!; + + private readonly Heading[] _headings = new Heading[] + { + new("Composition"), + new("Usage", [ + new("Standalone"), + new("Loading") + ]), + new("Custom Styles"), + new("API") + }; + + private readonly Slot[] _slots = new Slot[] + { + new(nameof(SkeletonSlots.Base), "The main container for the skeleton component."), + new(nameof(SkeletonSlots.Content), "The inner container that holds the actual content."), + }; + + private readonly string[] _apiComponents = new string[] + { + nameof(LumexSkeleton), + nameof(LumexCard), + nameof(LumexButton) + }; + + protected override void OnInitialized() + { + Layout.Initialize( + title: "Skeleton", + category: "Components", + description: "Skeletons are used as loading placeholders, indicating content is being loaded.", + headings: _headings, + linksProps: new ComponentLinksProps( "Skeleton", isServer: true ) + ); + } +} diff --git a/src/LumexUI/Components/Skeleton/LumexSkeleton.razor b/src/LumexUI/Components/Skeleton/LumexSkeleton.razor new file mode 100644 index 00000000..056b296c --- /dev/null +++ b/src/LumexUI/Components/Skeleton/LumexSkeleton.razor @@ -0,0 +1,15 @@ +@namespace LumexUI +@inherits LumexComponentBase + +@using S = SkeletonSlots; + + +
+ @ChildContent +
+
diff --git a/src/LumexUI/Components/Skeleton/LumexSkeleton.razor.cs b/src/LumexUI/Components/Skeleton/LumexSkeleton.razor.cs new file mode 100644 index 00000000..c47c6582 --- /dev/null +++ b/src/LumexUI/Components/Skeleton/LumexSkeleton.razor.cs @@ -0,0 +1,61 @@ +// 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 Microsoft.AspNetCore.Components; + +namespace LumexUI; + +/// +/// A component that represents a skeleton loader for displaying a placeholder while content is loading. +/// +public partial class LumexSkeleton : LumexComponentBase, ISlotComponent +{ + /// + /// Gets or sets the content to render inside the skeleton. + /// + [Parameter] public RenderFragment? ChildContent { get; set; } + + /// + /// Gets or sets a value indicating whether the skeleton is in a loading state. + /// + /// + /// The default value is . + /// + [Parameter] public bool Loading { get; set; } = true; + + /// + /// Gets or sets the CSS class names for the skeleton slots. + /// + [Parameter] public SkeletonSlots? Classes { get; set; } + + private Dictionary _slots = []; + + /// + protected override void OnParametersSet() + { + var skeleton = Styles.Skeleton.Style( TwMerge ); + _slots = skeleton(); + } + + [ExcludeFromCodeCoverage] + private string? GetStyles( string slot ) + { + if( !_slots.TryGetValue( slot, out var styles ) ) + { + throw new NotImplementedException(); + } + + return slot switch + { + nameof( SkeletonSlots.Base ) => styles( Classes?.Base, Class ), + nameof( SkeletonSlots.Content ) => styles( Classes?.Content ), + _ => throw new NotImplementedException() + }; + } +} \ No newline at end of file diff --git a/src/LumexUI/Components/Skeleton/SkeletonSlots.cs b/src/LumexUI/Components/Skeleton/SkeletonSlots.cs new file mode 100644 index 00000000..dd3b9fc7 --- /dev/null +++ b/src/LumexUI/Components/Skeleton/SkeletonSlots.cs @@ -0,0 +1,33 @@ +// 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 slot names for the , +/// used to assign CSS classes to different parts of the component. +/// +[ExcludeFromCodeCoverage] +public class SkeletonSlots : ISlot +{ + /// + /// Gets or sets the CSS class for the root slot. + /// + [Obsolete( "Deprecated. This will be removed in the future releases. Use the 'Base' slot instead." )] + public string? Root { get; } + + /// + /// Gets or sets the CSS class for the base slot. + /// + public string? Base { get; set; } + + /// + /// Gets or sets the CSS class for the content slot. + /// + public string? Content { get; set; } +} diff --git a/src/LumexUI/Styles/Skeleton.cs b/src/LumexUI/Styles/Skeleton.cs new file mode 100644 index 00000000..40bee585 --- /dev/null +++ b/src/LumexUI/Styles/Skeleton.cs @@ -0,0 +1,69 @@ +// 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.Utilities; + +using TailwindMerge; + +namespace LumexUI.Styles; + +[ExcludeFromCodeCoverage] +internal static class Skeleton +{ + 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( SkeletonSlots.Base )] = new ElementClass() + .Add( "group" ) + .Add( "relative" ) + .Add( "bg-surface3" ) + .Add( "overflow-hidden" ) + .Add( "pointer-events-none" ) + // before + .Add( "before:absolute" ) + .Add( "before:inset-0" ) + .Add( "before:opacity-100" ) + .Add( "before:-translate-x-full" ) + .Add( "before:bg-gradient-to-r" ) + .Add( "before:from-transparent" ) + .Add( "before:via-default-300" ) + .Add( "before:to-transparent" ) + .Add( "before:animate-shimmer" ) + //after + .Add( "after:-z-10" ) + .Add( "after:absolute" ) + .Add( "after:inset-0" ) + .Add( "after:opacity-100" ) + .Add( "after:bg-surface3" ) + // state + .Add( "data-[loading=false]:pointer-events-auto" ) + .Add( "data-[loading=false]:overflow-visible" ) + .Add( "data-[loading=false]:bg-transparent" ) + .Add( "data-[loading=false]:before:animate-none" ) + .Add( "data-[loading=false]:before:opacity-0" ) + .Add( "data-[loading=false]:after:opacity-0" ) + // transition + .Add( "duration-300" ) + .Add( "transition-background" ), + + [nameof( SkeletonSlots.Content )] = new ElementClass() + .Add( "opacity-0" ) + .Add( "group-data-[loading=false]:opacity-100" ) + // transition + .Add( "duration-300" ) + .Add( "transition-opacity" ) + .Add( "motion-reduce:transition-none" ) + } + } ); + } +} \ No newline at end of file diff --git a/src/LumexUI/Styles/_theme.css b/src/LumexUI/Styles/_theme.css index c0aa4064..5680f4ed 100644 --- a/src/LumexUI/Styles/_theme.css +++ b/src/LumexUI/Styles/_theme.css @@ -167,6 +167,7 @@ /* Animations */ --animate-enter: enter 200ms ease-out normal both; + --animate-shimmer: shimmer 2s infinite; @keyframes enter { 0% { @@ -180,6 +181,12 @@ } } + @keyframes shimmer { + 100% { + translate: 100%; + } + } + /* Override Defaults */ --default-transition-duration: 250ms; } diff --git a/tests/LumexUI.Tests/Components/Skeleton/SkeletonTests.razor b/tests/LumexUI.Tests/Components/Skeleton/SkeletonTests.razor new file mode 100644 index 00000000..a7605e80 --- /dev/null +++ b/tests/LumexUI.Tests/Components/Skeleton/SkeletonTests.razor @@ -0,0 +1,19 @@ +@namespace LumexUI.Tests.Components +@inherits TestContext + +@code { + public SkeletonTests() + { + Services.AddSingleton(); + } + + [Fact] + public void ShouldRenderCorrectly() + { + var action = () => Render( + @ + ); + + action.Should().NotThrow(); + } +}