Files
sufi-blazor/src/SufiChain.SufiBlazor/Components/Overlays/SbDialog.razor
T

373 lines
11 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@namespace SufiChain.SufiBlazor.Components.Overlays
@implements IAsyncDisposable
@inject IJSRuntime JSRuntime
@inject IStringLocalizer<SufiBlazorResource> L
<dialog @ref="_dialogRef"
class="@CssClass"
style="@EffectiveStyle"
@onkeydown="HandleKeyDown"
@oncancel="HandleCancel"
@oncancel:preventDefault="true"
@onclose="HandleNativeClose"
aria-labelledby="@(Title != null ? _titleId : null)"
aria-modal="true"
@attributes="FilteredAttributes">
<div class="sb-dialog__container" @onclick:stopPropagation="true">
@if (ShowHeader)
{
<header class="sb-dialog__header">
@if (Header != null)
{
@Header
}
else if (Title != null)
{
<h2 id="@_titleId" class="sb-dialog__title">@Title</h2>
}
@if (ShowCloseButton)
{
<button type="button"
class="sb-dialog__close-btn"
@onclick="HandleCloseButtonClick"
aria-label="@L["Close"]">
<span aria-hidden="true">×</span>
</button>
}
</header>
}
<div class="sb-dialog__body">
@ChildContent
</div>
@if (Footer != null)
{
<footer class="sb-dialog__footer">
@Footer
</footer>
}
</div>
</dialog>
@code {
private ElementReference _dialogRef;
private readonly string _titleId = $"sb-dialog-title-{Guid.NewGuid():N}";
private bool _isOpen;
private bool _hasRendered;
private bool _pendingOpenSync;
private bool _suppressNextNativeClose;
/// <summary>
/// Whether the dialog is open.
/// </summary>
[Parameter]
public bool Open { get; set; }
/// <summary>
/// Callback when open state changes.
/// </summary>
[Parameter]
public EventCallback<bool> OpenChanged { get; set; }
/// <summary>
/// Dialog title displayed in header.
/// </summary>
[Parameter]
public string? Title { get; set; }
/// <summary>
/// Custom header content (replaces Title).
/// </summary>
[Parameter]
public RenderFragment? Header { get; set; }
/// <summary>
/// Footer content for actions.
/// </summary>
[Parameter]
public RenderFragment? Footer { get; set; }
/// <summary>
/// Dialog body content.
/// </summary>
[Parameter]
public RenderFragment? ChildContent { get; set; }
/// <summary>
/// Dialog size preset (e.g. Lg). Used when Width/MaxWidth/Height/MaxHeight are not set; with Size alone the modal uses the preset dimensions.
/// </summary>
[Parameter]
public SbDialogSize Size { get; set; } = SbDialogSize.Md;
/// <summary>
/// Optional. Override width. Value is applied as-is to CSS (e.g. "800px", "80%", "50vw"). When set, overrides Size for width.
/// </summary>
[Parameter]
public string? Width { get; set; }
/// <summary>
/// Optional. Override max-width. Value is applied as-is to CSS (e.g. "800px", "80%", "1200px"). When set, overrides Size for max-width.
/// </summary>
[Parameter]
public string? MaxWidth { get; set; }
/// <summary>
/// Optional. Override height. Value is applied as-is to CSS (e.g. "80vh", "600px", "80%").
/// </summary>
[Parameter]
public string? Height { get; set; }
/// <summary>
/// Optional. Override max-height. Value is applied as-is to CSS (e.g. "90vh", "800px", "80%").
/// </summary>
[Parameter]
public string? MaxHeight { get; set; }
/// <summary>
/// Whether to close on Escape key.
/// </summary>
[Parameter]
public bool CloseOnEscape { get; set; } = true;
/// <summary>
/// Whether to close when clicking backdrop. Default is false so backdrop clicks do nothing.
/// </summary>
[Parameter]
public bool CloseOnBackdropClick { get; set; } = false;
/// <summary>
/// Whether to show the close button.
/// </summary>
[Parameter]
public bool ShowCloseButton { get; set; } = true;
/// <summary>
/// Optional callback when the header close (X) button is clicked.
/// Runs before the standard close flow; the dialog still invokes OpenChanged(false).
/// </summary>
[Parameter]
public EventCallback OnCloseButtonClick { get; set; }
/// <summary>
/// Callback when dialog closes with reason.
/// </summary>
[Parameter]
public EventCallback<SbDialogCloseReason> OnClose { get; set; }
/// <summary>
/// When true, the dialog body uses overflow: visible so dropdowns (e.g. SbSelect) inside the modal
/// are not clipped by the body. Use for modals that contain selects or other overflow menus.
/// </summary>
[Parameter]
public bool AllowDropdownOverflow { get; set; }
/// <summary>
/// Additional CSS classes.
/// </summary>
[Parameter]
public string? Class { get; set; }
/// <summary>
/// Inline styles.
/// </summary>
[Parameter]
public string? Style { get; set; }
/// <summary>
/// Additional HTML attributes.
/// </summary>
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }
private bool ShowHeader => Header != null || Title != null || ShowCloseButton;
private static string? GetDimensionValue(string? parameterValue, Dictionary<string, object>? attrs, params string[] keys)
{
if (!string.IsNullOrWhiteSpace(parameterValue))
return parameterValue.Trim();
if (attrs == null)
return null;
foreach (var key in keys)
{
if (attrs.TryGetValue(key, out var v) && v is string s && !string.IsNullOrWhiteSpace(s))
return s.Trim();
}
return null;
}
private bool HasDimensionOverrides
{
get
{
var w = GetDimensionValue(Width, AdditionalAttributes, "Width", "width");
var mw = GetDimensionValue(MaxWidth, AdditionalAttributes, "MaxWidth", "max-width", "maxwidth");
var h = GetDimensionValue(Height, AdditionalAttributes, "Height", "height");
var mh = GetDimensionValue(MaxHeight, AdditionalAttributes, "MaxHeight", "max-height", "maxheight");
return w != null || mw != null || h != null || mh != null;
}
}
private IReadOnlyDictionary<string, object>? FilteredAttributes
{
get
{
if (AdditionalAttributes == null || AdditionalAttributes.Count == 0)
return AdditionalAttributes;
var omit = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "width", "maxwidth", "max-width", "height", "maxheight", "max-height" };
var filtered = new Dictionary<string, object>(AdditionalAttributes);
foreach (var key in AdditionalAttributes.Keys)
{
if (omit.Contains(key))
filtered.Remove(key);
}
return filtered.Count == AdditionalAttributes.Count ? AdditionalAttributes : filtered;
}
}
private string EffectiveStyle
{
get
{
var parts = new List<string>();
var w = GetDimensionValue(Width, AdditionalAttributes, "Width", "width");
if (w != null)
parts.Add($"width: {w}");
var mw = GetDimensionValue(MaxWidth, AdditionalAttributes, "MaxWidth", "max-width", "maxwidth");
if (mw != null)
parts.Add($"max-width: {mw}");
var h = GetDimensionValue(Height, AdditionalAttributes, "Height", "height");
if (h != null)
parts.Add($"height: {h}");
var mh = GetDimensionValue(MaxHeight, AdditionalAttributes, "MaxHeight", "max-height", "maxheight");
if (mh != null)
parts.Add($"max-height: {mh}");
if (!string.IsNullOrWhiteSpace(Style))
parts.Add(Style.Trim());
return parts.Count > 0 ? string.Join("; ", parts) : string.Empty;
}
}
private string CssClass
{
get
{
var classes = new List<string> { "sb-dialog" };
if (!HasDimensionOverrides)
classes.Add($"sb-dialog--{Size.ToString().ToLowerInvariant()}");
if (AllowDropdownOverflow)
classes.Add("sb-dialog--allow-dropdown-overflow");
if (!string.IsNullOrWhiteSpace(Class))
classes.Add(Class);
return string.Join(" ", classes);
}
}
protected override async Task OnParametersSetAsync()
{
if (Open != _isOpen)
{
_isOpen = Open;
await SyncNativeOpenStateAsync();
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
_hasRendered = true;
if (_pendingOpenSync)
{
await SyncNativeOpenStateAsync();
}
}
private async Task ShowAsync()
{
await JSRuntime.InvokeVoidAsync("SufiBlazor.dialog.showModal", _dialogRef);
}
private async Task HideAsync()
{
await JSRuntime.InvokeVoidAsync("SufiBlazor.dialog.close", _dialogRef);
}
private async Task SyncNativeOpenStateAsync()
{
if (!_hasRendered)
{
_pendingOpenSync = true;
return;
}
_pendingOpenSync = false;
if (_isOpen)
{
await ShowAsync();
}
else
{
await HideAsync();
}
}
private async Task HandleCloseButtonClick()
{
if (OnCloseButtonClick.HasDelegate)
{
await OnCloseButtonClick.InvokeAsync();
}
await CloseAsync(SbDialogCloseReason.CloseButton);
}
private async Task CloseAsync(SbDialogCloseReason reason)
{
_suppressNextNativeClose = true;
_isOpen = false;
await SyncNativeOpenStateAsync();
await OnClose.InvokeAsync(reason);
await OpenChanged.InvokeAsync(false);
}
private async Task HandleKeyDown(KeyboardEventArgs args)
{
if (args.Key == "Escape" && CloseOnEscape)
{
await CloseAsync(SbDialogCloseReason.Escape);
}
}
private async Task HandleCancel()
{
// Native dialog cancel event (Escape key)
if (CloseOnEscape)
{
await CloseAsync(SbDialogCloseReason.Escape);
}
}
private async Task HandleNativeClose()
{
if (_suppressNextNativeClose)
{
_suppressNextNativeClose = false;
return;
}
if (!_isOpen)
{
return;
}
_isOpen = false;
await OnClose.InvokeAsync(SbDialogCloseReason.CloseButton);
await OpenChanged.InvokeAsync(false);
}
public async ValueTask DisposeAsync()
{
// Cleanup if needed
}
}