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> /// <summary>
/// Represents the current edit state of the MgGrid /// Represents the current edit state of the MgGrid
/// </summary> /// </summary>
public enum MgEditState public enum MgGridEditState
{ {
/// <summary> /// <summary>
/// No edit operation in progress /// No edit operation in progress

View File

@ -21,11 +21,21 @@ public interface IMgGridBase : IGrid
/// Indicates whether any synchronization operation is in progress /// Indicates whether any synchronization operation is in progress
/// </summary> /// </summary>
bool IsSyncing { get; } bool IsSyncing { get; }
/// <summary> /// <summary>
/// Current edit state of the grid (None, New, Edit) /// Current edit state of the grid (None, New, Edit)
/// </summary> /// </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 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; public bool IsSyncing => _dataSource?.IsSyncing ?? false;
/// <inheritdoc /> /// <inheritdoc />
public MgEditState EditState { get; private set; } = MgEditState.None; public MgGridEditState GridEditState { get; private set; } = MgGridEditState.None;
[Parameter] public bool ShowInfoPanel { get; set; } = true; [Parameter] public bool ShowInfoPanel { get; set; } = true;
@ -137,7 +147,8 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
builder.OpenComponent<DxSplitter>(seq++); builder.OpenComponent<DxSplitter>(seq++);
builder.AddAttribute(seq++, "Width", "100%"); 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 => builder.AddAttribute(seq++, "Panes", (RenderFragment)(panesBuilder =>
{ {
@ -151,12 +162,13 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
})); }));
panesBuilder.CloseComponent(); panesBuilder.CloseComponent();
// Right pane - InfoPanel // Right pane - InfoPanel (sticky to viewport)
panesBuilder.OpenComponent<DxSplitterPane>(paneSeq++); panesBuilder.OpenComponent<DxSplitterPane>(paneSeq++);
panesBuilder.AddAttribute(paneSeq++, "Size", "350px"); panesBuilder.AddAttribute(paneSeq++, "Size", "400px");
panesBuilder.AddAttribute(paneSeq++, "MinSize", "300px"); panesBuilder.AddAttribute(paneSeq++, "MinSize", "0px");
panesBuilder.AddAttribute(paneSeq++, "MaxSize", "800px"); panesBuilder.AddAttribute(paneSeq++, "MaxSize", "100%");
panesBuilder.AddAttribute(paneSeq++, "AllowCollapse", true); panesBuilder.AddAttribute(paneSeq++, "AllowCollapse", true);
panesBuilder.AddAttribute(paneSeq++, "CssClass", "mg-info-panel-pane");
panesBuilder.AddAttribute(paneSeq++, "ChildContent", (RenderFragment)(infoPanelBuilder => panesBuilder.AddAttribute(paneSeq++, "ChildContent", (RenderFragment)(infoPanelBuilder =>
{ {
@ -434,15 +446,18 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
} }
// Set edit state // Set edit state
EditState = e.IsNew ? MgEditState.New : MgEditState.Edit; GridEditState = e.IsNew ? MgGridEditState.New : MgGridEditState.Edit;
await OnGridCustomizeEditModel.InvokeAsync(e); await OnGridCustomizeEditModel.InvokeAsync(e);
// Frissítjük az InfoPanel-t edit módba - itt az EditModel már elérhető // Frissítjük az InfoPanel-t edit módba - itt az EditModel már elérhető
if (ShowInfoPanel && _infoPanelInstance != null) if (ShowInfoPanel && _infoPanelInstance != null)
{ {
_infoPanelInstance.SetEditMode(editModel); _infoPanelInstance.SetEditMode(editModel);
} }
// Force grid refresh to apply edit mode styling
await InvokeAsync(StateHasChanged);
} }
private async Task OnEditStart(GridEditStartEventArgs e) private async Task OnEditStart(GridEditStartEventArgs e)
@ -455,9 +470,20 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
{ {
_focusedDataItem = e.DataItem; _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); await OnGridFocusedRowChanged.InvokeAsync(e);
@ -491,23 +517,29 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
else await UpdateDataItemAsync(dataItem); else await UpdateDataItemAsync(dataItem);
// Kilépés edit módból // Kilépés edit módból
EditState = MgEditState.None; GridEditState = MgGridEditState.None;
if (ShowInfoPanel && _infoPanelInstance != null) if (ShowInfoPanel && _infoPanelInstance != null)
{ {
_infoPanelInstance.ClearEditMode(); _infoPanelInstance.ClearEditMode();
} }
// Force grid refresh to remove edit mode styling
await InvokeAsync(StateHasChanged);
} }
private async Task OnEditCanceling(GridEditCancelingEventArgs e) private async Task OnEditCanceling(GridEditCancelingEventArgs e)
{ {
// Kilépés edit módból // Kilépés edit módból
EditState = MgEditState.None; GridEditState = MgGridEditState.None;
if (ShowInfoPanel && _infoPanelInstance != null) if (ShowInfoPanel && _infoPanelInstance != null)
{ {
_infoPanelInstance.ClearEditMode(); _infoPanelInstance.ClearEditMode();
} }
// Force grid refresh to remove edit mode styling
await InvokeAsync(StateHasChanged);
} }
private Task SaveChangesToServerAsync() private Task SaveChangesToServerAsync()
@ -570,6 +602,27 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
e.Column.Visible = AcDomain.IsDeveloperVersion; e.Column.Visible = AcDomain.IsDeveloperVersion;
e.Column.ShowInColumnChooser = 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) protected override async Task SetParametersAsyncCore(ParameterView parameters)
@ -647,6 +700,31 @@ public abstract class MgGridBase<TSignalRDataSource, TDataItem, TId, TLoggerClie
return _dataSource.LoadDataSourceAsync(false); 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() public async ValueTask DisposeAsync()
{ {
if (_isDisposed) return; if (_isDisposed) return;

View File

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

View File

@ -1,10 +1,18 @@
using DevExpress.Blazor; using DevExpress.Blazor;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
namespace AyCode.Blazor.Components.Components.Grids; 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 DxGrid? _currentGrid;
private TDataItem? _currentDataItem; private TDataItem? _currentDataItem;
private int _focusedRowVisibleIndex = -1; private int _focusedRowVisibleIndex = -1;
@ -17,6 +25,30 @@ public partial class MgGridInfoPanel<TDataItem> : ComponentBase where TDataItem
// Cache for edit settings to avoid repeated lookups // Cache for edit settings to avoid repeated lookups
private readonly Dictionary<string, IEditSettings?> _editSettingsCache = []; 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> /// <summary>
/// Refreshes the InfoPanel with data from the specified grid row (view mode) /// Refreshes the InfoPanel with data from the specified grid row (view mode)
/// </summary> /// </summary>
@ -87,6 +119,26 @@ public partial class MgGridInfoPanel<TDataItem> : ComponentBase where TDataItem
StateHasChanged(); 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> /// <summary>
/// Gets the data item to display/edit (EditModel in edit mode, otherwise CurrentDataItem) /// Gets the data item to display/edit (EditModel in edit mode, otherwise CurrentDataItem)
/// </summary> /// </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 { .mg-grid-info-panel {
height: 100%; container-type: inline-size;
overflow-y: auto; container-name: infopanel;
padding: 1rem;
background-color: var(--dxbl-bg-secondary, #f8f9fa); background-color: var(--dxbl-bg-secondary, #f8f9fa);
transition: background-color 0.3s ease, border-color 0.3s ease; 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 { .mg-grid-info-panel.edit-mode {
background-color: #fffbeb; background-color: #fffbeb !important;
border-left: 3px solid #f59e0b; border-left: 3px solid #f59e0b !important;
} }
.mg-grid-info-panel.view-mode { .mg-grid-info-panel.view-mode {
background-color: #f8f9fa; background-color: #f8f9fa !important;
border-left: 3px solid transparent; 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 { .info-panel-form {
width: 100%; width: 100%;
} }
@ -30,17 +87,13 @@
color: var(--dxbl-primary, #0d6efd); color: var(--dxbl-primary, #0d6efd);
} }
.info-panel-empty { /* Text overflow handling - show ellipsis and full text in tooltip */
display: flex; .info-panel-text-wrapper {
align-items: center; width: 100%;
justify-content: center;
height: 100%;
color: var(--dxbl-text-secondary, #6c757d);
font-style: italic;
} }
.info-panel-empty p { .info-panel-text-wrapper input[readonly] {
margin: 0; text-overflow: ellipsis;
text-align: center; overflow: hidden;
padding: 2rem; 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;
}
};