Refactor grid InfoPanel: sticky, responsive, new icons

- Redesigned MgGridInfoPanel to use a sticky, scroll-aware layout via JavaScript for better UX when scrolling.
- InfoPanel now uses a responsive CSS grid layout with container queries for 1/2/3 column display based on width.
- Added new toolbar icons using SVG masks for a modern, consistent look; updated toolbar item class names.
- Added "Prev Row" and "Next Row" navigation buttons to the grid toolbar, with corresponding methods in grid base classes.
- Unified edit state enum naming to MgGridEditState and updated all references.
- Improved InfoPanel cell rendering for better text overflow handling and tooltips.
- Updated CSS for InfoPanel and grid, including sticky pane support and icon styles.
- Registered mgGridInfoPanel.js in App.razor and index.html for JS interop.
- Minor UI/UX tweaks: InfoPanel header, background colors, and panel sizing.
This commit is contained in:
Loretta 2025-12-17 10:20:17 +01:00
parent 45294199cf
commit 109a4b82b4
6 changed files with 405 additions and 80 deletions

View File

@ -3,7 +3,7 @@ namespace AyCode.Blazor.Components.Components.Grids;
/// <summary>
/// Represents the current edit state of the MgGrid
/// </summary>
public enum MgEditState
public enum MgGridEditState
{
/// <summary>
/// No edit operation in progress

View File

@ -21,11 +21,21 @@ public interface IMgGridBase : IGrid
/// Indicates whether any synchronization operation is in progress
/// </summary>
bool IsSyncing { get; }
/// <summary>
/// Current edit state of the grid (None, New, Edit)
/// </summary>
MgEditState EditState { get; }
MgGridEditState GridEditState { get; }
/// <summary>
/// Navigates to the previous row in the grid
/// </summary>
void StepPrevRow();
/// <summary>
/// Navigates to the next row in the grid
/// </summary>
void StepNextRow();
}
public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClient> : DxGrid, IMgGridBase, IAsyncDisposable
@ -49,7 +59,7 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
public bool IsSyncing => _dataSource?.IsSyncing ?? false;
/// <inheritdoc />
public MgEditState EditState { get; private set; } = MgEditState.None;
public MgGridEditState GridEditState { get; private set; } = MgGridEditState.None;
[Parameter] public bool ShowInfoPanel { get; set; } = true;
@ -137,7 +147,8 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
builder.OpenComponent<DxSplitter>(seq++);
builder.AddAttribute(seq++, "Width", "100%");
builder.AddAttribute(seq++, "Height", "100%");
builder.AddAttribute(seq++, "CssClass", "mg-grid-splitter");
builder.AddAttribute(seq++, "Orientation", Orientation.Horizontal);
builder.AddAttribute(seq++, "Panes", (RenderFragment)(panesBuilder =>
{
@ -151,12 +162,13 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
}));
panesBuilder.CloseComponent();
// Right pane - InfoPanel
// Right pane - InfoPanel (sticky to viewport)
panesBuilder.OpenComponent<DxSplitterPane>(paneSeq++);
panesBuilder.AddAttribute(paneSeq++, "Size", "350px");
panesBuilder.AddAttribute(paneSeq++, "MinSize", "300px");
panesBuilder.AddAttribute(paneSeq++, "MaxSize", "800px");
panesBuilder.AddAttribute(paneSeq++, "Size", "400px");
panesBuilder.AddAttribute(paneSeq++, "MinSize", "0px");
panesBuilder.AddAttribute(paneSeq++, "MaxSize", "100%");
panesBuilder.AddAttribute(paneSeq++, "AllowCollapse", true);
panesBuilder.AddAttribute(paneSeq++, "CssClass", "mg-info-panel-pane");
panesBuilder.AddAttribute(paneSeq++, "ChildContent", (RenderFragment)(infoPanelBuilder =>
{
@ -434,15 +446,18 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
}
// Set edit state
EditState = e.IsNew ? MgEditState.New : MgEditState.Edit;
GridEditState = e.IsNew ? MgGridEditState.New : MgGridEditState.Edit;
await OnGridCustomizeEditModel.InvokeAsync(e);
// Frissítjük az InfoPanel-t edit módba - itt az EditModel már elérhető
if (ShowInfoPanel && _infoPanelInstance != null)
{
_infoPanelInstance.SetEditMode(editModel);
}
// Force grid refresh to apply edit mode styling
await InvokeAsync(StateHasChanged);
}
private async Task OnEditStart(GridEditStartEventArgs e)
@ -455,9 +470,20 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
{
_focusedDataItem = e.DataItem;
if (ShowInfoPanel && _infoPanelInstance != null && e.DataItem is TDataItem dataItem)
if (ShowInfoPanel && _infoPanelInstance != null)
{
_infoPanelInstance.RefreshData(this, dataItem, e.VisibleIndex);
// Ha edit módban vagyunk, de a felhasználó egy másik sorra kattintott,
// akkor kilépünk az edit módból
if (GridEditState != MgGridEditState.None)
{
_infoPanelInstance.ClearEditMode();
}
// Frissítjük az InfoPanel-t az új sor adataival
if (e.DataItem is TDataItem dataItem)
{
_infoPanelInstance.RefreshData(this, dataItem, e.VisibleIndex);
}
}
await OnGridFocusedRowChanged.InvokeAsync(e);
@ -491,23 +517,29 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
else await UpdateDataItemAsync(dataItem);
// Kilépés edit módból
EditState = MgEditState.None;
GridEditState = MgGridEditState.None;
if (ShowInfoPanel && _infoPanelInstance != null)
{
_infoPanelInstance.ClearEditMode();
}
// Force grid refresh to remove edit mode styling
await InvokeAsync(StateHasChanged);
}
private async Task OnEditCanceling(GridEditCancelingEventArgs e)
{
// Kilépés edit módból
EditState = MgEditState.None;
GridEditState = MgGridEditState.None;
if (ShowInfoPanel && _infoPanelInstance != null)
{
_infoPanelInstance.ClearEditMode();
}
// Force grid refresh to remove edit mode styling
await InvokeAsync(StateHasChanged);
}
private Task SaveChangesToServerAsync()
@ -570,6 +602,27 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
e.Column.Visible = AcDomain.IsDeveloperVersion;
e.Column.ShowInColumnChooser = AcDomain.IsDeveloperVersion;
}
// Apply edit mode background to the row being edited
if (e.ElementType == GridElementType.DataRow && GridEditState != MgGridEditState.None)
{
if (e.VisibleIndex == GetFocusedRowIndex())
{
e.Style = string.IsNullOrEmpty(e.Style)
? "background-color: #fffbeb;"
: e.Style + " background-color: #fffbeb;";
}
}
// Apply edit mode background to cells in the edited row
else if (e.ElementType == GridElementType.DataCell && GridEditState != MgGridEditState.None)
{
if (e.VisibleIndex == GetFocusedRowIndex())
{
e.Style = string.IsNullOrEmpty(e.Style)
? "background-color: #fffbeb;"
: e.Style + " background-color: #fffbeb;";
}
}
}
protected override async Task SetParametersAsyncCore(ParameterView parameters)
@ -647,6 +700,31 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
return _dataSource.LoadDataSourceAsync(false);
}
/// <summary>
/// Navigates to the previous row in the grid
/// </summary>
public void StepPrevRow()
{
var currentIndex = GetFocusedRowIndex();
if (currentIndex > 0)
{
SetFocusedRowIndex(currentIndex - 1);
}
}
/// <summary>
/// Navigates to the next row in the grid
/// </summary>
public void StepNextRow()
{
var currentIndex = GetFocusedRowIndex();
var visibleRowCount = GetVisibleRowCount();
if (currentIndex >= 0 && currentIndex < visibleRowCount - 1)
{
SetFocusedRowIndex(currentIndex + 1);
}
}
public async ValueTask DisposeAsync()
{
if (_isDisposed) return;

View File

@ -3,50 +3,53 @@
@using System.Reflection
@typeparam TDataItem where TDataItem : class
<div class="mg-grid-info-panel @(_isEditMode ? "edit-mode" : "view-mode")">
@if (GetActiveDataItem() != null && _currentGrid != null)
{
var colSpan = _allDataColumns.Count > 10 ? 6 : 12;
var dataItem = GetActiveDataItem()!;
<div @ref="_panelElement" class="mg-grid-info-panel @(_isEditMode ? "edit-mode" : "view-mode")">
@* Header - matches grid toolbar height *@
<div class="dxbl-grid-header-panel px-3 py-2 border-bottom">
<span class="fw-semibold">Sor részletei</span>
</div>
<DxFormLayout CssClass="info-panel-form"
CaptionPosition="CaptionPosition.Vertical"
SizeMode="SizeMode.Small">
@foreach (var column in _allDataColumns)
{
var displayText = GetDisplayTextFromGrid(column);
var value = GetCellValue(column);
var settingsType = GetEditSettingsType(column);
var isReadOnly = !_isEditMode || column.ReadOnly;
<DxFormLayoutItem Caption="@GetColumnCaption(column)"
CaptionCssClass="@GetCaptionCssClass(isReadOnly)"
ColSpanXxl="@colSpan"
ColSpanXl="@colSpan"
ColSpanLg="@colSpan"
ColSpanMd="@colSpan"
ColSpanSm="@colSpan"
ColSpanXs="@colSpan">
<Template>
@if (_isEditMode && !column.ReadOnly)
{
@RenderEditableCell(column, dataItem, value, displayText, settingsType)
}
else
{
@RenderCellContent(column, value, displayText, settingsType)
}
</Template>
</DxFormLayoutItem>
}
</DxFormLayout>
}
else
{
<div class="info-panel-empty">
<p>Válasszon ki egy sort az adatok megtekintéséhez</p>
</div>
}
@* Content - scrollable area *@
<div class="mg-info-panel-content">
@if (GetActiveDataItem() != null && _currentGrid != null)
{
var dataItem = GetActiveDataItem()!;
<div class="mg-info-panel-grid">
@foreach (var column in _allDataColumns)
{
var displayText = GetDisplayTextFromGrid(column);
var value = GetCellValue(column);
var settingsType = GetEditSettingsType(column);
var isReadOnly = !_isEditMode || column.ReadOnly;
<div class="mg-info-panel-item">
<div class="dxbl-form-layout-item">
<label class="dxbl-fl-lc @GetCaptionCssClass(isReadOnly) d-block mb-1 small">
@GetColumnCaption(column)
</label>
<div class="dxbl-fl-ec">
@if (_isEditMode && !column.ReadOnly)
{
@RenderEditableCell(column, dataItem, value, displayText, settingsType)
}
else
{
@RenderCellContent(column, value, displayText, settingsType)
}
</div>
</div>
</div>
}
</div>
}
else
{
<div class="text-center text-muted py-5">
<p>Válasszon ki egy sort az adatok megtekintéséhez</p>
</div>
}
</div>
</div>
@code {
@ -539,10 +542,14 @@
{
case EditSettingsType.ComboBox:
// ComboBox columns show resolved display text
builder.OpenElement(seq++, "div");
builder.AddAttribute(seq++, "title", displayText);
builder.AddAttribute(seq++, "class", "info-panel-text-wrapper");
builder.OpenComponent<DxTextBox>(seq++);
builder.AddAttribute(seq++, "Text", displayText);
builder.AddAttribute(seq++, "ReadOnly", true);
builder.CloseComponent();
builder.CloseElement();
return;
case EditSettingsType.CheckBox when value is bool boolVal:
@ -561,19 +568,26 @@
return;
case EditSettingsType.SpinEdit:
builder.OpenElement(seq++, "div");
builder.AddAttribute(seq++, "title", displayText);
builder.AddAttribute(seq++, "class", "info-panel-text-wrapper");
builder.OpenComponent<DxTextBox>(seq++);
builder.AddAttribute(seq++, "Text", displayText);
builder.AddAttribute(seq++, "ReadOnly", true);
builder.AddAttribute(seq++, "CssClass", "text-end");
builder.CloseComponent();
builder.CloseElement();
return;
case EditSettingsType.Memo:
builder.OpenElement(seq++, "div");
builder.AddAttribute(seq++, "title", displayText);
builder.OpenComponent<DxMemo>(seq++);
builder.AddAttribute(seq++, "Text", displayText);
builder.AddAttribute(seq++, "ReadOnly", true);
builder.AddAttribute(seq++, "Rows", 3);
builder.CloseComponent();
builder.CloseElement();
return;
}
@ -618,18 +632,26 @@
break;
case decimal or double or float or int or long or short:
builder.OpenElement(seq++, "div");
builder.AddAttribute(seq++, "title", displayText);
builder.AddAttribute(seq++, "class", "info-panel-text-wrapper");
builder.OpenComponent<DxTextBox>(seq++);
builder.AddAttribute(seq++, "Text", displayText);
builder.AddAttribute(seq++, "ReadOnly", true);
builder.AddAttribute(seq++, "CssClass", "text-end");
builder.CloseComponent();
builder.CloseElement();
break;
default:
builder.OpenElement(seq++, "div");
builder.AddAttribute(seq++, "title", displayText);
builder.AddAttribute(seq++, "class", "info-panel-text-wrapper");
builder.OpenComponent<DxTextBox>(seq++);
builder.AddAttribute(seq++, "Text", displayText);
builder.AddAttribute(seq++, "ReadOnly", true);
builder.CloseComponent();
builder.CloseElement();
break;
}
};

View File

@ -1,10 +1,18 @@
using DevExpress.Blazor;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
namespace AyCode.Blazor.Components.Components.Grids;
public partial class MgGridInfoPanel<TDataItem> : ComponentBase where TDataItem : class
public partial class MgGridInfoPanel<TDataItem> : ComponentBase, IAsyncDisposable where TDataItem : class
{
[Inject] private IJSRuntime JSRuntime { get; set; } = null!;
private ElementReference _panelElement;
private IJSObjectReference? _jsModule;
private bool _isJsInitialized;
private const int DefaultTopOffset = 300; // Increased from 180 to account for header + tabs + toolbar
private DxGrid? _currentGrid;
private TDataItem? _currentDataItem;
private int _focusedRowVisibleIndex = -1;
@ -17,6 +25,30 @@ public partial class MgGridInfoPanel<TDataItem> : ComponentBase where TDataItem
// Cache for edit settings to avoid repeated lookups
private readonly Dictionary<string, IEditSettings?> _editSettingsCache = [];
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await InitializeStickyAsync();
}
}
private async Task InitializeStickyAsync()
{
try
{
await JSRuntime.InvokeVoidAsync(
"MgGridInfoPanel.initSticky",
_panelElement,
DefaultTopOffset);
_isJsInitialized = true;
}
catch (JSException)
{
// JS might not be loaded yet, ignore
}
}
/// <summary>
/// Refreshes the InfoPanel with data from the specified grid row (view mode)
/// </summary>
@ -87,6 +119,26 @@ public partial class MgGridInfoPanel<TDataItem> : ComponentBase where TDataItem
StateHasChanged();
}
public async ValueTask DisposeAsync()
{
if (_isJsInitialized)
{
try
{
await JSRuntime.InvokeVoidAsync("MgGridInfoPanel.disposeSticky", _panelElement);
}
catch
{
// Ignore disposal errors
}
}
if (_jsModule != null)
{
await _jsModule.DisposeAsync();
}
}
/// <summary>
/// Gets the data item to display/edit (EditModel in edit mode, otherwise CurrentDataItem)
/// </summary>

View File

@ -1,21 +1,78 @@
/* Shared edit mode background color configuration - change only here */
/* Grid row background: #fffbeb (see MgGridBase.cs OnCustomizeElement) */
/* InfoPanel background: #fffbeb (see below .edit-mode) */
/* Border color: #f59e0b */
/* Breakpoint configuration - CHANGE ONLY THESE VALUES */
/* 2 column breakpoint: 500px */
/* 3 column breakpoint: 800px */
/* Main panel - contained within splitter pane */
.mg-grid-info-panel {
height: 100%;
overflow-y: auto;
padding: 1rem;
container-type: inline-size;
container-name: infopanel;
background-color: var(--dxbl-bg-secondary, #f8f9fa);
transition: background-color 0.3s ease, border-color 0.3s ease;
display: flex;
flex-direction: column;
overflow: hidden;
/* Prevent panel from pushing out the splitter */
min-height: 0;
max-height: 100%;
}
.mg-grid-info-panel.edit-mode {
background-color: #fffbeb;
border-left: 3px solid #f59e0b;
background-color: #fffbeb !important;
border-left: 3px solid #f59e0b !important;
}
.mg-grid-info-panel.view-mode {
background-color: #f8f9fa;
border-left: 3px solid transparent;
background-color: #f8f9fa !important;
border-left: 3px solid transparent !important;
}
/* Content area - scrollable, takes remaining space */
.mg-info-panel-content {
flex: 1 1 0;
overflow-y: auto;
overflow-x: hidden;
padding: 1rem;
min-height: 0; /* Critical for flex child to allow shrinking */
}
/* Grid layout with responsive column wrapping based on panel width */
.mg-info-panel-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
}
/* 1 column for narrow panels (< 500px) */
@container infopanel (max-width: 499px) {
.mg-info-panel-grid {
grid-template-columns: 1fr;
}
}
/* 2 columns for medium width (500px - 799px) */
@container infopanel (min-width: 500px) and (max-width: 799px) {
.mg-info-panel-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* 3 columns for wider panels (>= 800px) */
@container infopanel (min-width: 800px) {
.mg-info-panel-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.mg-info-panel-item {
min-width: 0; /* Prevent grid blowout */
}
/* Fallback styles */
.info-panel-form {
width: 100%;
}
@ -30,17 +87,13 @@
color: var(--dxbl-primary, #0d6efd);
}
.info-panel-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--dxbl-text-secondary, #6c757d);
font-style: italic;
/* Text overflow handling - show ellipsis and full text in tooltip */
.info-panel-text-wrapper {
width: 100%;
}
.info-panel-empty p {
margin: 0;
text-align: center;
padding: 2rem;
}
.info-panel-text-wrapper input[readonly] {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}

View File

@ -0,0 +1,120 @@
// MgGridInfoPanel - Sticky scroll handling
// Makes the InfoPanel sticky to viewport when scrolling
window.MgGridInfoPanel = {
observers: new Map(),
// Initialize sticky behavior for an InfoPanel element
initSticky: function (element, topOffset) {
if (!element) return;
const elementId = element.id || this.generateId(element);
// Clean up existing observer if any
this.disposeSticky(element);
// Store the initial position of the element (relative to document)
const rect = element.getBoundingClientRect();
const initialTop = rect.top + window.scrollY;
// Calculate and set initial state
this.updatePosition(element, initialTop);
// Handler to update position on scroll and resize
const updateHandler = () => {
this.updatePosition(element, initialTop);
};
// Add event listeners - use passive to not block scrolling
window.addEventListener('resize', updateHandler, { passive: true });
window.addEventListener('scroll', updateHandler, { passive: true });
// Store cleanup info
this.observers.set(elementId, {
element: element,
updateHandler: updateHandler,
initialTop: initialTop
});
return true;
},
// Dispose sticky behavior
disposeSticky: function (element) {
if (!element) return;
const elementId = element.id || this.findElementId(element);
const observer = this.observers.get(elementId);
if (observer) {
window.removeEventListener('resize', observer.updateHandler);
window.removeEventListener('scroll', observer.updateHandler);
// Reset styles
element.style.height = '';
element.style.maxHeight = '';
element.style.transform = '';
this.observers.delete(elementId);
}
},
// Update panel position and height based on scroll
updatePosition: function (element, initialTop) {
if (!element) return;
const scrollY = window.scrollY;
const viewportHeight = window.innerHeight;
const bottomPadding = 30; // 30px from bottom
// Calculate how much we've scrolled past the initial position
const scrolledPast = Math.max(0, scrollY - initialTop);
// Get the splitter pane to know our container limits
const paneContainer = element.closest('.dxbl-splitter-pane');
let maxScrollOffset = Infinity;
if (paneContainer) {
// Don't scroll past the bottom of the pane
const paneHeight = paneContainer.offsetHeight;
const elementHeight = element.offsetHeight;
maxScrollOffset = Math.max(0, paneHeight - elementHeight);
}
// Clamp the scroll offset
const translateY = Math.min(scrolledPast, maxScrollOffset);
// Apply transform to make it "sticky"
element.style.transform = `translateY(${translateY}px)`;
// Calculate height: from current visual position to viewport bottom
const rect = element.getBoundingClientRect();
const visualTop = rect.top; // This already accounts for transform
// Height from current visual top to viewport bottom minus padding
const availableHeight = viewportHeight - visualTop - bottomPadding;
// Clamp height
const finalHeight = Math.max(200, Math.min(availableHeight, viewportHeight - bottomPadding));
element.style.height = finalHeight + 'px';
element.style.maxHeight = finalHeight + 'px';
},
// Generate a unique ID for the element
generateId: function (element) {
const id = 'mg-info-panel-' + Math.random().toString(36).substr(2, 9);
element.id = id;
return id;
},
// Find element ID from stored observers
findElementId: function (element) {
for (const [id, observer] of this.observers.entries()) {
if (observer.element === element) {
return id;
}
}
return null;
}
};