Add scroll-to-item support to MgCardView component

- Introduced `Height`, `ScrollToItem`, and `ItemKeySelector` parameters to MgCardView for scroll targeting and internal scroll area.
- Cards now have stable DOM ids for precise JS-based scrolling.
- Added mgCardView.js with smooth scroll logic; included script in app.
- Updated CSS for scrollable card area.
- Updated MeasuringOut.razor to use new scroll features and fixed time format.
This commit is contained in:
Loretta 2026-03-23 05:32:15 +01:00
parent 40223f9182
commit 873ffe91d2
4 changed files with 117 additions and 30 deletions

View File

@ -1,31 +1,36 @@
@typeparam TItem
@if (ShowFilterPanel && FilterPanel is not null)
{
<div class="mg-card-filter-panel">
@FilterPanel
</div>
}
@if (Data is { Count: > 0 })
{
<div class="mg-card-grid @CssClass"
style="--cols-xs: @ColumnCountXs; --cols-sm: @ColumnCountSm; --cols-lg: @ColumnCountLg;">
@foreach (var item in PagedItems)
{
<div class="mg-card @CardCssClass"
@onclick="() => OnCardClickInternal(item)"
style="@(OnCardClick.HasDelegate ? "cursor: pointer;" : "")">
@CardTemplate(item)
</div>
}
</div>
@if (ShowPager && Data.Count > PageSize)
<div class="mg-card-view-container" style="@ContainerStyle">
@if (ShowFilterPanel && FilterPanel is not null)
{
<DxPager PageCount="@((int)Math.Ceiling((double)Data.Count / PageSize))"
ActivePageIndex="_activePageIndex"
ActivePageIndexChanged="OnActivePageIndexChanged"
CssClass="mt-2" />
<div class="mg-card-filter-panel">
@FilterPanel
</div>
}
}
@if (Data is { Count: > 0 })
{
<div class="mg-card-scroll-area">
<div class="mg-card-grid @CssClass"
style="--cols-xs: @ColumnCountXs; --cols-sm: @ColumnCountSm; --cols-lg: @ColumnCountLg;">
@foreach (var item in PagedItems)
{
<div id="@GetCardElementId(item)"
class="mg-card @CardCssClass"
@onclick="() => OnCardClickInternal(item)"
style="@(OnCardClick.HasDelegate ? "cursor: pointer;" : "")">
@CardTemplate(item)
</div>
}
</div>
</div>
@if (ShowPager && Data.Count > PageSize)
{
<DxPager PageCount="@((int)Math.Ceiling((double)Data.Count / PageSize))"
ActivePageIndex="_activePageIndex"
ActivePageIndexChanged="OnActivePageIndexChanged"
CssClass="mt-2" />
}
}
</div>

View File

@ -1,15 +1,17 @@
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
namespace AyCode.Blazor.Components.Components.CardViews;
/// <summary>
/// Generic card view component that displays items in a responsive grid layout.
/// Uses DxGridLayout + DxLayoutBreakpoint for responsive column management
/// and DxPager for optional pagination.
/// Generic card view component that displays items in a responsive CSS Grid layout
/// with optional pagination and scroll-to-item support.
/// </summary>
/// <typeparam name="TItem">The type of data item displayed in each card.</typeparam>
public partial class MgCardView<TItem> : ComponentBase
{
[Inject] private IJSRuntime JSRuntime { get; set; } = null!;
/// <summary>
/// The collection of items to display as cards.
/// </summary>
@ -70,6 +72,12 @@ public partial class MgCardView<TItem> : ComponentBase
[Parameter]
public string? CardCssClass { get; set; }
/// <summary>
/// Height of the card view container (e.g., "500px", "70vh"). When set, the component uses its own scroll area.
/// </summary>
[Parameter]
public string? Height { get; set; }
/// <summary>
/// Whether to show the filter panel above the cards. Default: false.
/// </summary>
@ -82,7 +90,22 @@ public partial class MgCardView<TItem> : ComponentBase
[Parameter]
public RenderFragment? FilterPanel { get; set; }
/// <summary>
/// Item to scroll into view after render. Set to null to disable.
/// </summary>
[Parameter]
public TItem? ScrollToItem { get; set; }
/// <summary>
/// Key selector for identifying items (e.g., item => item.Id). Required when ScrollToItem is used.
/// </summary>
[Parameter]
public Func<TItem, object>? ItemKeySelector { get; set; }
private int _activePageIndex;
private object? _lastScrolledKey;
private string? ContainerStyle => Height is not null ? $"height: {Height};" : null;
private IReadOnlyList<TItem> PagedItems
{
@ -108,4 +131,33 @@ public partial class MgCardView<TItem> : ComponentBase
{
_activePageIndex = newPageIndex;
}
/// <summary>
/// Generates a stable DOM element id for a card item using the key selector.
/// </summary>
private string? GetCardElementId(TItem item)
{
return ItemKeySelector is null ? null : $"mg-card-{ItemKeySelector(item)}";
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (ScrollToItem is not null && ItemKeySelector is not null)
{
var key = ItemKeySelector(ScrollToItem);
if (!Equals(key, _lastScrolledKey))
{
_lastScrolledKey = key;
var elementId = $"mg-card-{key}";
try
{
await JSRuntime.InvokeVoidAsync("MgCardView.scrollToElement", elementId);
}
catch (JSException)
{
// JS might not be loaded yet
}
}
}
}
}

View File

@ -1,3 +1,15 @@
.mg-card-view-container {
display: flex;
flex-direction: column;
overflow: hidden;
}
.mg-card-scroll-area {
flex: 1;
overflow-y: auto;
padding-right: 4px;
}
.mg-card-grid {
display: grid;
gap: 1rem;

View File

@ -0,0 +1,18 @@
// MgCardView - Scroll handling
window.MgCardView = {
scrollToElement: function (elementId) {
const element = document.getElementById(elementId);
if (!element) return;
// Find the closest scroll container
const scrollArea = element.closest('.mg-card-scroll-area');
if (scrollArea) {
const containerRect = scrollArea.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const offset = elementRect.top - containerRect.top - (containerRect.height - elementRect.height) / 2;
scrollArea.scrollBy({ top: offset, behavior: 'smooth' });
} else {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
};