UI/Components/ScrollBox/ScrollBox.razor

A UI Razor component for a scrollable box. It renders a viewport and optional vertical/horizontal scrollbars, computes thumb sizes/positions each Tick, supports stick-to-bottom behavior, and exposes ScrollToBottom.

File Access
@namespace Sunless.Libraries.UI
@using System
@using Sandbox
@using Sandbox.UI
@inherits Panel
@attribute [StyleSheet]

<root class="scroll-box @(Vertical ? "is-vertical" : "") @(Horizontal ? "is-horizontal" : "") @(StickToBottom ? "is-stick-bottom" : "")">
	<div class="scroll-box-viewport" @ref="Viewport">
		@ChildContent
	</div>

	@if ( _showVerticalBar )
	{
		<div class="scroll-box-bar scroll-box-bar--vertical">
			<div class="scroll-box-thumb" style="@VerticalThumbStyle"></div>
		</div>
	}

	@if ( _showHorizontalBar )
	{
		<div class="scroll-box-bar scroll-box-bar--horizontal">
			<div class="scroll-box-thumb" style="@HorizontalThumbStyle"></div>
		</div>
	}
</root>

@code
{
	[Parameter] public new RenderFragment ChildContent { get; set; }
	[Parameter] public bool Vertical { get; set; } = true;
	[Parameter] public bool Horizontal { get; set; }
	[Parameter] public bool StickToBottom { get; set; }

	public Panel Viewport { get; set; }

	bool _showVerticalBar;
	bool _showHorizontalBar;
	float _vThumbPct;
	float _vThumbPos;
	float _hThumbPct;
	float _hThumbPos;

	string VerticalThumbStyle => $"height: {_vThumbPct * 100:0.##}%; top: {_vThumbPos * 100:0.##}%;";
	string HorizontalThumbStyle => $"width: {_hThumbPct * 100:0.##}%; left: {_hThumbPos * 100:0.##}%;";

	public override void Tick()
	{
		base.Tick();

		if ( !Viewport.IsValid() )
			return;

		if ( StickToBottom )
			Viewport.PreferScrollToBottom = true;

		var size = Viewport.Box.Rect.Size;
		var scrollSize = Viewport.ScrollSize;
		var offset = Viewport.ScrollOffset;

		bool newV = Vertical && scrollSize.y > 0.5f;
		bool newH = Horizontal && scrollSize.x > 0.5f;

		if ( newV )
		{
			var total = size.y + scrollSize.y;
			_vThumbPct = total > 0 ? Math.Clamp( size.y / total, 0.05f, 1f ) : 1f;
			var maxScroll = Math.Max( scrollSize.y, 0.0001f );
			var travel = 1f - _vThumbPct;
			_vThumbPos = Math.Clamp( offset.y / maxScroll, 0f, 1f ) * travel;
		}

		if ( newH )
		{
			var total = size.x + scrollSize.x;
			_hThumbPct = total > 0 ? Math.Clamp( size.x / total, 0.05f, 1f ) : 1f;
			var maxScroll = Math.Max( scrollSize.x, 0.0001f );
			var travel = 1f - _hThumbPct;
			_hThumbPos = Math.Clamp( offset.x / maxScroll, 0f, 1f ) * travel;
		}

		if ( newV != _showVerticalBar || newH != _showHorizontalBar )
		{
			_showVerticalBar = newV;
			_showHorizontalBar = newH;
			StateHasChanged();
		}
		else if ( newV || newH )
		{
			StateHasChanged();
		}
	}

	public void ScrollToBottom()
	{
		if ( Viewport.IsValid() )
			Viewport.TryScrollToBottom();
	}

	protected override int BuildHash() => HashCode.Combine( Vertical, Horizontal, StickToBottom, _showVerticalBar, _showHorizontalBar );
}