Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ public class NavigationStore
.Add( new( nameof( LumexSwitch ) ) )
.Add( new( nameof( LumexTabs ) ) )
.Add( new( nameof( LumexTextbox ) ) )
.Add( new( nameof( LumexTooltip ), PageStatus.New ) );
.Add( new( nameof( LumexTooltip ), PageStatus.New ) )
.Add( new( nameof( LumexUser ), PageStatus.New ) );

private static NavigationCategory ComponentsApiCategory =>
new NavigationCategory( "Components API" )
Expand Down Expand Up @@ -95,7 +96,8 @@ public class NavigationStore
.Add( new( nameof( LumexTab ) ) )
.Add( new( nameof( LumexTabs ) ) )
.Add( new( nameof( LumexTextbox ) ) )
.Add( new( nameof( LumexThemeProvider ) ) );
.Add( new( nameof( LumexThemeProvider ) ) )
.Add( new( nameof( LumexUser ) ) );

public static Navigation GetNavigation()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<LumexUser Name="Dan">
<AvatarContent>
<LumexAvatar Src="https://avatars.githubusercontent.com/u/68395709?v=4" />
</AvatarContent>
<DescriptionContent>
<LumexLink External Href="https://github.com/desmondinho">
@@desmondinho
</LumexLink>
</DescriptionContent>
</LumexUser>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<LumexUser Name="Jane Doe" Description="Product Designer">
<AvatarContent>
<LumexAvatar Src="https://i.pravatar.cc/300" />
</AvatarContent>
</LumexUser>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@rendermode InteractiveWebAssembly

<PreviewCode Code="@new(name: null, snippet: "User.Code.LinkDescription")">
<LumexUI.Docs.Client.Pages.Components.User.Examples.LinkDescription />
</PreviewCode>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@rendermode InteractiveWebAssembly

<PreviewCode Code="@new(name: null, snippet: "User.Code.Usage")">
<LumexUI.Docs.Client.Pages.Components.User.Examples.Usage />
</PreviewCode>
67 changes: 67 additions & 0 deletions docs/LumexUI.Docs.Client/Pages/Components/User/User.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
@page "/docs/components/user"
@layout DocsContentLayout

@using LumexUI.Docs.Client.Pages.Components.User.PreviewCodes

<DocsSection Title="Usage">
<Usage />

<DocsSection Title="Link Description">
<LinkDescription />
</DocsSection>

<LumexAlert Radius="@Radius.Large" Class="my-6">
<DescriptionContent>
When both <code>Description</code> and <code>DescriptionContent</code> are provided, <code>DescriptionContent</code> takes precedence.
</DescriptionContent>
</LumexAlert>

</DocsSection>

<DocsSlotsSection Slots="@_slots">
<div>
<h4 class="font-semibold">User</h4>
<ul>
<li><code>Class</code>: The basic CSS class name styles the wrapper of the user contents.</li>
<li><code>Classes</code>: The CSS class names for the user slots style the entire user at once.</li>
</ul>
</div>
</DocsSlotsSection>

<DocsApiSection Components="@_apiComponents" />

@code {
[CascadingParameter] private DocsContentLayout Layout { get; set; } = default!;

private readonly Heading[] _headings = new Heading[]
{
new("Usage", [
new("Link Description")
]),
new("API")
};

private readonly Slot[] _slots = new Slot[]
{
new(nameof( UserSlots.Base ), "The base slot of the user, it is the main container."),
new(nameof( UserSlots.Wrapper ), "The name and description wrapper."),
new(nameof( UserSlots.Name ), "The name of the user."),
new(nameof( UserSlots.Description ), "The description of the user."),
};

private readonly string[] _apiComponents = new string[]
{
nameof(LumexUser)
};

protected override void OnInitialized()
{
Layout.Initialize(
title: "User",
category: "Components",
description: "Display user information with avatar and name.",
headings: _headings,
linksProps: new ComponentLinksProps("Usr", isServer: true)
);
}
}
30 changes: 30 additions & 0 deletions src/LumexUI/Components/User/LumexUser.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
@namespace LumexUI
@inherits LumexComponentBase

@using S = UserSlots;

<LumexComponent As="@As"
Class="@GetStyles( nameof(S.Base) )"
Style="@RootStyle"
data-slot="base"
@attributes="AdditionalAttributes">
@AvatarContent
<div class="@GetStyles( nameof( S.Wrapper ) )" data-slot="wrapper">

<span class="@GetStyles( nameof( S.Name ) )" data-slot="name">
@Name
</span>

<span class="@GetStyles( nameof( S.Description ) )" data-slot="description">
@if ( DescriptionContent is not null )
{
@DescriptionContent
}
else
{
@Description
}
</span>

</div>
</LumexComponent>
78 changes: 78 additions & 0 deletions src/LumexUI/Components/User/LumexUser.razor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// 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;

/// <summary>
/// A component that represents user information, such as an avatar, name, and email.
/// </summary>
public partial class LumexUser : LumexComponentBase, ISlotComponent<UserSlots>
{
/// <summary>
/// Gets or sets the content to render when using a custom description element.
/// </summary>
[Parameter] public RenderFragment? DescriptionContent { get; set; }

/// <summary>
/// Gets or sets the content to render for the avatar element.
/// </summary>
[Parameter] public RenderFragment? AvatarContent { get; set; }

/// <summary>
/// Gets or sets the name of the user.
/// </summary>
[Parameter] public string? Name { get; set; }

/// <summary>
/// Gets or sets the description of the user.
/// </summary>
[Parameter] public string? Description { get; set; }

/// <summary>
/// Gets or sets the whether the user is focusable.
/// </summary>
[Parameter] public bool IsFocusable { get; set; }

/// <summary>
/// Gets or sets the CSS class names for the user slots.
/// </summary>
[Parameter] public UserSlots? Classes { get; set; }
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is a missing property for the 'Avatar Props', but I'm unsure how to handle the class. Should I use a dedicated class? Or there are already some examples in the codebase?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I need to think about that...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is actually sad :D I see a couple of options:

  • Create the AvatarProps class and pass it as parameter. Flaw: Avatar props duplicates + need to always keep them synced;
  • Create the AvatarProps parameter of type Dictionary<string, object> and pass it via attribute splatting (@attributes=...) to LumexAvatar. Flaw: we lose strict typing and intelliSense;
  • Create the AvatarContent parameter of type RenderFragment to allow consumers define avatars themselves.

I think that the last option is the best here.

I have plans to move to the following structure in the future:

<Alert>
  <Alert.Icon />
  <Alert.Content>
    <Alert.Title>Success</Alert.Title>
    <Alert.Description>Your changes have been saved.</Alert.Description>
  </Alert.Content>
  <Alert.Close />
</Alert>

, where each slot is a separate component that can be styled and arranged independently.
This is something shadcn has been doing for a while alredy, and HeroUI v3 moved to the same concept. With this approach we will gain maximum flexibility as well.

Copy link
Contributor

@desmondinho desmondinho Sep 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I just realized that LumexChip already does that (AvatarContent approach) :D Can't remember own library solutions haha

Copy link
Contributor

@desmondinho desmondinho Sep 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But don't wrap AvatarContent with the condition as I did -- it does not make sense.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have plans to move to the following structure in the future, where each slot is a separate component that can be styled and arranged independently.

Oh fantastic! I love this approach 🚀

Ok, so for now I add this 'AvatarContent' as RenderFragment

Copy link
Contributor Author

@Denny09310 Denny09310 Sep 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it cool for you, if after this component is reviewed I make a branch to try and convert all the components with old styling method to the new? Just to have the project prepared for the architectural shift. I'd like to help, but I don't want to be too overwhelming 😁

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it cool for you, if after this component is reviewed I make a branch to try and convert all the components with old styling method to the new? Just to have the project prepared for the architectural shift. I'd like to help, but I don't want to be too overwhelming 😁

Yes, I’m totally fine with it! I really appreciate your input. I will do my best to make the process as smooth as possible for you.

I have to let you know that the utility for the new styling method is far from ideal, so we’ll most likely need to update it a bit.


private Dictionary<string, ComponentSlot> _slots = [];

/// <inheritdoc/>
protected override void OnParametersSet()
{
var user = Styles.User.Style( TwMerge );
_slots = user( new()
{
[nameof( IsFocusable )] = IsFocusable.ToString(),
} );
}

[ExcludeFromCodeCoverage]
private string? GetStyles( string slot )
{
if( !_slots.TryGetValue( slot, out var styles ) )
{
throw new NotImplementedException();
}

return slot switch
{
nameof( UserSlots.Base ) => styles( Classes?.Base, Class ),
nameof( UserSlots.Wrapper ) => styles( Classes?.Wrapper ),
nameof( UserSlots.Name ) => styles( Classes?.Name ),
nameof( UserSlots.Description ) => styles( Classes?.Description ),
_ => throw new NotImplementedException()
};
}
}
31 changes: 31 additions & 0 deletions src/LumexUI/Components/User/UserSlots.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright (c) LumexUI 2024
// LumexUI licenses this file to you under the MIT license
// See the license here https://github.com/LumexUI/lumexui/blob/main/LICENSE

using System.Diagnostics.CodeAnalysis;

using LumexUI.Common;

namespace LumexUI;

/// <summary>
/// Represents the set of customizable slots for the <see cref="LumexUser"/> component.
/// </summary>
[ExcludeFromCodeCoverage]
public class UserSlots : SlotBase
{
/// <summary>
/// Gets or sets the CSS class for the wrapper slot.
/// </summary>
public string? Wrapper { get; set; }

/// <summary>
/// Gets or sets the CSS class for the name slot.
/// </summary>
public string? Name { get; set; }

/// <summary>
/// Gets or sets the CSS class for the description slot.
/// </summary>
public string? Description { get; set; }
}
54 changes: 54 additions & 0 deletions src/LumexUI/Styles/User.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// 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 User
{
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(UserSlots.Base)] = new ElementClass()
.Add( "inline-flex" )
.Add( "items-center" )
.Add( "justify-center" )
.Add( "gap-2" )
.Add( "rounded-small" )
.Add( "outline-solid" )
.Add( "outline-transparent" ),

[nameof(UserSlots.Wrapper)] = "inline-flex flex-col items-start",

[nameof(UserSlots.Name)] = "text-small text-inherit",

[nameof(UserSlots.Description)] = "text-tiny text-foreground-400",
},

Variants = new VariantCollection
{
[nameof(LumexUser.IsFocusable)] = new VariantValueCollection
{
[bool.TrueString] = new SlotCollection
{
[nameof(UserSlots.Base)] = Utils.FocusVisible,
}
}
}
} );
}
}
46 changes: 46 additions & 0 deletions tests/LumexUI.Tests/Components/User/UserTests.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
@namespace LumexUI.Tests.Components
@inherits TestContext

@code {
public UserTests()
{
Services.AddSingleton<TwMerge>();
}

[Fact]
public void ShouldRenderCorrectly()
{
var action = () => Render(
@<LumexUser Name="John Doe" />
);

action.Should().NotThrow();
}

[Fact]
public void ShouldRenderCorrectlyWithDescription()
{
var action = () => Render(
@<LumexUser Name="John Doe" Description="Software Engeneer" />
);

action.Should().NotThrow();
}

[Fact]
public void ShouldRenderDescriptionContent()
{
var cut = Render(
@<LumexUser Name="John Doe" data-testid="user-root">
<DescriptionContent>
<LumexLink External Href="https://github.com/desmondinho" data-testid="user-link">
@@desmondinho
</LumexLink>
</DescriptionContent>
</LumexUser>
);

cut.FindByTestId("user-root").Should().NotBeNull();
cut.FindByTestId("user-link").Should().NotBeNull();
}
}
Loading