Skip to content

Commit

Permalink
feat(components): add collapse component (#16)
Browse files Browse the repository at this point in the history
* feat(utils): allow adding existing value to the ElementStyle

* feat(components): add extension for `ElementReference` to get scroll height

* feat(components): add the collapse component

* test(components): add tests for the collapse component (not all)

* test(components): nits
  • Loading branch information
desmondinho authored May 25, 2024
1 parent 4e630bb commit 13655b4
Show file tree
Hide file tree
Showing 13 changed files with 394 additions and 67 deletions.
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,5 @@ FodyWeavers.xsd
*.sln.iml

# Automatically generated assets
/src/LumexUI/wwwroot/*
/docs/LumexUI.Docs/wwwroot/css/*
/docs/LumexUI.Docs/*.exe
39 changes: 22 additions & 17 deletions src/LumexUI.Utilities/ElementStyle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
// 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;

namespace LumexUI.Utilities;

/// <summary>
Expand Down Expand Up @@ -48,23 +46,30 @@ public ElementStyle( string property, string? value )
/// </exception>
public static ElementStyle Default( string property, string? value ) => new( property, value );

/// <summary>
/// Adds a style to the current <see cref="ElementStyle"/> instance.
/// </summary>
/// <param name="property">The CSS property name.</param>
/// <param name="value">The value of the CSS property.</param>
/// <returns>An <see cref="ElementStyle"/> instance.</returns>
/// <exception cref="ArgumentNullException">
/// Thrown if <paramref name="property"/> is null, empty, or consists exclusively of white-space characters.
/// </exception>
public ElementStyle Add( string property, string? value )
/// <summary>
/// Adds a style to the current <see cref="ElementStyle"/> instance if the value is not null or whitespace.
/// </summary>
/// <param name="value">The value of the CSS property.</param>
/// <returns>An <see cref="ElementStyle"/> instance.</returns>
public ElementStyle Add( string? value ) => !string.IsNullOrWhiteSpace( value ) ? AddRaw( $"{value};" ) : this;

/// <summary>
/// Adds a style to the current <see cref="ElementStyle"/> instance.
/// </summary>
/// <param name="property">The CSS property name.</param>
/// <param name="value">The value of the CSS property.</param>
/// <returns>An <see cref="ElementStyle"/> instance.</returns>
/// <exception cref="ArgumentNullException">
/// Thrown if <paramref name="property"/> is null, empty, or consists exclusively of white-space characters.
/// </exception>
public ElementStyle Add( string property, string? value )
{
if( string.IsNullOrWhiteSpace( property ) )
{
throw new ArgumentNullException( property, "CSS property value cannot be null, empty or consist exlusively of white-space characters." );
}

return Add( $"{property}:{value};" );
return AddRaw( $"{property}:{value};" );
}

/// <summary>
Expand Down Expand Up @@ -120,15 +125,15 @@ public ElementStyle Add( string property, string? value )
/// </summary>
/// <param name="elementStyle">The <see cref="ElementStyle"/> instance whose styles will be added.</param>
/// <returns>An <see cref="ElementStyle"/> instance.</returns>
public ElementStyle Add( ElementStyle elementStyle ) => Add( elementStyle.ToString() );
public ElementStyle Add( ElementStyle elementStyle ) => AddRaw( elementStyle.ToString() );

/// <summary>
/// Conditionally adds the styles from another <see cref="ElementStyle"/> instance to the current instance.
/// </summary>
/// <param name="elementStyle">The <see cref="ElementStyle"/> instance whose styles will be added.</param>
/// <param name="when">A boolean value that determines whether the styles should be added.</param>
/// <returns>An <see cref="ElementStyle"/> instance.</returns>
public ElementStyle Add( ElementStyle elementStyle, bool when ) => when ? Add( elementStyle.ToString() ) : this;
public ElementStyle Add( ElementStyle elementStyle, bool when ) => when ? AddRaw( elementStyle.ToString() ) : this;

/// <summary>
/// Conditionally adds the styles from another <see cref="ElementStyle"/> instance to the current instance.
Expand All @@ -154,7 +159,7 @@ public ElementStyle Add( IReadOnlyDictionary<string, object>? additionalAttribut
{
if( value is not null )
{
return Add( value.ToString() );
return AddRaw( value.ToString() );
}
}

Expand All @@ -168,7 +173,7 @@ public ElementStyle Add( IReadOnlyDictionary<string, object>? additionalAttribut
public readonly override string ToString()
=> !string.IsNullOrEmpty( _stringBuffer ) ? _stringBuffer.Trim() : string.Empty;

private ElementStyle Add( string? value )
private ElementStyle AddRaw( string? value )
{
_stringBuffer += value;
return this;
Expand Down
155 changes: 155 additions & 0 deletions src/LumexUI/Components/Collapse/LumexCollapse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// 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.Extensions;
using LumexUI.Styles;
using LumexUI.Utilities;

using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.JSInterop;

namespace LumexUI;

public class LumexCollapse : LumexComponentBase
{
/// <summary>
/// Gets or sets content to be rendered inside the collapse.
/// </summary>
[Parameter] public RenderFragment? ChildContent { get; set; }

/// <summary>
/// Gets or sets a value indicating whether the collapse is expanded.
/// </summary>
[Parameter] public bool Expanded { get; set; }

/// <summary>
/// Gets or sets the callback to be invoked when a collapse/expand transition ends.
/// </summary>
[Parameter] public EventCallback OnTransitionEnd { get; set; }

internal CollapseState State { get; set; }

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

private protected override string RootStyle =>
ElementStyle.Empty()
.Add( "height", $"{_height}px", when: State is CollapseState.Collapsing or CollapseState.Expanding )
.Add( base.RootStyle )
.ToString();

private int _height;
private bool _expanded;
private bool _isRendered;
private bool _heightUpdated;
private ElementReference _collapse;

/// <inheritdoc />
protected override void BuildRenderTree( RenderTreeBuilder builder )
{
builder.OpenElement( 0, As );
builder.AddAttribute( 1, "class", RootClass );
builder.AddAttribute( 2, "style", RootStyle );
builder.AddAttribute( 3, "ontransitionend", EventCallback.Factory.Create( this, OnTransitionEndAsync ) );
builder.AddMultipleAttributes( 4, AdditionalAttributes );
builder.AddElementReferenceCapture( 5, elementReference => _collapse = elementReference );
builder.AddContent( 6, ChildContent );
builder.CloseElement();
}

/// <inheritdoc />
protected override void OnParametersSet()
{
// We don't want to call `UpdateHeightAsync` every time the component is rendered.
if( _expanded == Expanded )
{
return;
}

_expanded = Expanded;

if( _isRendered )
{
_heightUpdated = false;
SetTransitionState();
}
else if( _expanded )
{
State = CollapseState.Expanded;
}
}

/// <inheritdoc />
protected override async Task OnAfterRenderAsync( bool firstRender )
{
if( _heightUpdated )
{
return;
}

if( firstRender )
{
_isRendered = true;
await UpdateHeightAsync();
return;
}

if( State is CollapseState.Expanding or CollapseState.Collapsing )
{
await UpdateHeightAsync();
StateHasChanged();
}
}

private async ValueTask UpdateHeightAsync()
{
try
{
_height = await _collapse.GetScrollHeightAsync();

if( State is CollapseState.Collapsing )
{
_height = 0;
}

_heightUpdated = true;
}
catch( Exception ex ) when( ex is JSDisconnectedException or TaskCanceledException )
{
_height = 0;
}
}

private Task OnTransitionEndAsync()
{
if( State is CollapseState.Expanding )
{
State = CollapseState.Expanded;
}
else if( State is CollapseState.Collapsing )
{
State = CollapseState.Collapsed;
}

return OnTransitionEnd.InvokeAsync();
}

private void SetTransitionState()
{
if( State is CollapseState.Expanded )
{
State = CollapseState.Collapsing;
}
else if( State is CollapseState.Collapsed )
{
State = CollapseState.Expanding;
}
}

internal enum CollapseState
{
Collapsed, Expanding, Expanded, Collapsing
}
}
35 changes: 35 additions & 0 deletions src/LumexUI/Extensions/ElementReferenceExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// 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 System.Reflection;

using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;

namespace LumexUI.Extensions;

[ExcludeFromCodeCoverage]
public static class ElementReferenceExtensions
{
private static readonly PropertyInfo? _jsRuntimeProp = typeof( WebElementReferenceContext )
.GetProperty( "JSRuntime", BindingFlags.Instance | BindingFlags.NonPublic );

public static ValueTask<int> GetScrollHeightAsync( this ElementReference elementReference )
{
var jsRuntime = elementReference.GetJSRuntime();
return jsRuntime.InvokeAsync<int>( "Lumex.elementReference.getScrollHeight", elementReference );
}

private static IJSRuntime GetJSRuntime( this ElementReference elementReference )
{
if( elementReference.Context is not WebElementReferenceContext context )
{
throw new InvalidOperationException( "ElementReference has not been configured correctly." );
}

var jsRuntime = (IJSRuntime?)_jsRuntimeProp?.GetValue( context );
return jsRuntime ?? throw new InvalidOperationException( "No JavaScript runtime found." );
}
}
1 change: 1 addition & 0 deletions src/LumexUI/LumexUI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@
<ItemGroup>
<InternalsVisibleTo Include="LumexUI.Tests" />
</ItemGroup>

</Project>
32 changes: 32 additions & 0 deletions src/LumexUI/Styles/Collapse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// 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 static LumexUI.LumexCollapse;

namespace LumexUI.Styles;

[ExcludeFromCodeCoverage]
internal readonly record struct Collapse
{
private static ElementClass GetStateStyles( CollapseState state )
{
var transitioning = state is CollapseState.Expanding or CollapseState.Collapsing;

return ElementClass.Empty()
.Add( "hidden", when: state is CollapseState.Collapsed )
.Add( "h-0 overflow-hidden transition-[height]", when: transitioning );
}

public static string GetStyles( LumexCollapse collapse )
{
return ElementClass.Empty()
.Add( GetStateStyles( collapse.State ) )
.Add( collapse.Class )
.ToString();
}
}
11 changes: 11 additions & 0 deletions src/LumexUI/wwwroot/js/LumexUI.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// 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

import { elementReference } from './elementReference.js'

export const Lumex = {
elementReference
};

window['Lumex'] = Lumex
15 changes: 15 additions & 0 deletions src/LumexUI/wwwroot/js/elementReference.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// 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

function getScrollHeight(element) {
if (!element) {
throw "No element found!";
}

return element.scrollHeight;
}

export const elementReference = {
getScrollHeight
}
2 changes: 0 additions & 2 deletions tests/LumexUI.Tests/Components/Card/CardTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
// LumexUI licenses this file to you under the MIT license
// See the license here https://github.com/LumexUI/lumexui/blob/main/LICENSE

using System.Linq;

using Microsoft.Extensions.DependencyInjection;

using TailwindMerge;
Expand Down
Loading

0 comments on commit 13655b4

Please sign in to comment.