This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SufiChain.SufiBlazor.Components.Overlays;
|
||||
|
||||
internal class PopoverConstraintResult
|
||||
{
|
||||
[JsonPropertyName("shiftX")]
|
||||
public int ShiftX { get; set; }
|
||||
|
||||
[JsonPropertyName("shiftY")]
|
||||
public int ShiftY { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
@namespace SufiChain.SufiBlazor.Components.Overlays
|
||||
@using SufiChain.SufiBlazor.Components.Common
|
||||
@using SufiChain.SufiBlazor.Components.Actions
|
||||
@inject IStringLocalizer<SufiBlazorResource> L
|
||||
@inject IStringLocalizer<SufiBlazorResource> L
|
||||
|
||||
@if (_isOpen)
|
||||
{
|
||||
<div class="sb-confirm-dialog-overlay" @onclick="OnOverlayClick">
|
||||
<div class="sb-confirm-dialog sb-confirm-dialog--@Variant.ToString().ToLower() @Class" @onclick:stopPropagation="true" role="alertdialog" aria-modal="true">
|
||||
@if (Icon != null)
|
||||
{
|
||||
<div class="sb-confirm-dialog__icon sb-confirm-dialog__icon--@Variant.ToString().ToLower()">
|
||||
@Icon
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="sb-confirm-dialog__icon sb-confirm-dialog__icon--@Variant.ToString().ToLower()">
|
||||
<SbIcon Name="@GetDefaultIconName()" Size="SbSize.Lg" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="sb-confirm-dialog__content">
|
||||
<h3 class="sb-confirm-dialog__title">@Title</h3>
|
||||
@if (!string.IsNullOrEmpty(Message))
|
||||
{
|
||||
<p class="sb-confirm-dialog__message">@Message</p>
|
||||
}
|
||||
@if (ChildContent != null)
|
||||
{
|
||||
<div class="sb-confirm-dialog__body">
|
||||
@ChildContent
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="sb-confirm-dialog__actions">
|
||||
@if (ShowCancelButton)
|
||||
{
|
||||
<SbButton Variant="SbButtonVariant.Ghost" OnClick="Cancel">@(CancelText ?? L["Cancel"])</SbButton>
|
||||
}
|
||||
<SbButton Variant="SbButtonVariant.Solid"
|
||||
Color="@GetConfirmButtonColor()"
|
||||
OnClick="Confirm">
|
||||
@(ConfirmText ?? L["Confirm"])
|
||||
</SbButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private bool _isOpen;
|
||||
private TaskCompletionSource<bool>? _tcs;
|
||||
|
||||
/// <summary>
|
||||
/// Dialog title.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string Title { get; set; } = "Confirm";
|
||||
|
||||
/// <summary>
|
||||
/// Dialog message.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string? Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Dialog content.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Custom icon.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment? Icon { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Dialog variant.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SbConfirmDialogVariant Variant { get; set; } = SbConfirmDialogVariant.Default;
|
||||
|
||||
/// <summary>
|
||||
/// Confirm button text.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string? ConfirmText { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cancel button text.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string? CancelText { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to show cancel button.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool ShowCancelButton { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether clicking outside closes the dialog.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool CloseOnOverlayClick { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Callback when confirmed.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback OnConfirm { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Callback when cancelled.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback OnCancel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional CSS classes.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string? Class { get; set; }
|
||||
|
||||
private string GetDefaultIconName() => Variant switch
|
||||
{
|
||||
SbConfirmDialogVariant.Danger => "warning",
|
||||
SbConfirmDialogVariant.Warning => "warning",
|
||||
SbConfirmDialogVariant.Info => "info",
|
||||
_ => "help-circle"
|
||||
};
|
||||
|
||||
private SbColor GetConfirmButtonColor() => Variant switch
|
||||
{
|
||||
SbConfirmDialogVariant.Danger => SbColor.Danger,
|
||||
SbConfirmDialogVariant.Warning => SbColor.Warning,
|
||||
SbConfirmDialogVariant.Info => SbColor.Info,
|
||||
_ => SbColor.Primary
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Show the dialog and wait for result.
|
||||
/// </summary>
|
||||
public Task<bool> ShowAsync()
|
||||
{
|
||||
_tcs = new TaskCompletionSource<bool>();
|
||||
_isOpen = true;
|
||||
StateHasChanged();
|
||||
return _tcs.Task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Show the dialog.
|
||||
/// </summary>
|
||||
public void Show()
|
||||
{
|
||||
_isOpen = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Close the dialog.
|
||||
/// </summary>
|
||||
public void Close()
|
||||
{
|
||||
_isOpen = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task Confirm()
|
||||
{
|
||||
_isOpen = false;
|
||||
_tcs?.TrySetResult(true);
|
||||
await OnConfirm.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task Cancel()
|
||||
{
|
||||
_isOpen = false;
|
||||
_tcs?.TrySetResult(false);
|
||||
await OnCancel.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task OnOverlayClick()
|
||||
{
|
||||
if (CloseOnOverlayClick)
|
||||
{
|
||||
await Cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace SufiChain.SufiBlazor.Components.Overlays;
|
||||
|
||||
/// <summary>
|
||||
/// Variants for confirm dialog.
|
||||
/// </summary>
|
||||
public enum SbConfirmDialogVariant
|
||||
{
|
||||
Default,
|
||||
Danger,
|
||||
Warning,
|
||||
Info
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
@namespace SufiChain.SufiBlazor.Components.Overlays
|
||||
@implements IDisposable
|
||||
@inject IJSRuntime JSRuntime
|
||||
|
||||
<div class="sb-context-menu-trigger" @ref="_triggerRef" @oncontextmenu="OnContextMenu" @oncontextmenu:preventDefault="true">
|
||||
@ChildContent
|
||||
</div>
|
||||
|
||||
@if (_isOpen)
|
||||
{
|
||||
<div class="sb-context-menu-overlay" @onclick="Close">
|
||||
<div class="sb-context-menu @Class"
|
||||
style="left: @(_x)px; top: @(_y)px"
|
||||
@onclick:stopPropagation="true">
|
||||
@MenuContent
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private ElementReference _triggerRef;
|
||||
private bool _isOpen;
|
||||
private double _x;
|
||||
private double _y;
|
||||
|
||||
/// <summary>
|
||||
/// Trigger content.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Menu content.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment? MenuContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the context menu is disabled.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool Disabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Callback before menu opens.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback<SbContextMenuEventArgs> OnOpen { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Callback when menu closes.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback OnClose { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional CSS classes.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string? Class { get; set; }
|
||||
|
||||
private async Task OnContextMenu(MouseEventArgs e)
|
||||
{
|
||||
if (Disabled) return;
|
||||
|
||||
var args = new SbContextMenuEventArgs { X = e.ClientX, Y = e.ClientY };
|
||||
await OnOpen.InvokeAsync(args);
|
||||
|
||||
if (!args.Cancel)
|
||||
{
|
||||
_x = e.ClientX;
|
||||
_y = e.ClientY;
|
||||
_isOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Close the context menu.
|
||||
/// </summary>
|
||||
public async Task Close()
|
||||
{
|
||||
_isOpen = false;
|
||||
await OnClose.InvokeAsync();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Cleanup if needed
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context menu event arguments.
|
||||
/// </summary>
|
||||
public class SbContextMenuEventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// X coordinate.
|
||||
/// </summary>
|
||||
public double X { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Y coordinate.
|
||||
/// </summary>
|
||||
public double Y { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to cancel opening the menu.
|
||||
/// </summary>
|
||||
public bool Cancel { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace SufiChain.SufiBlazor.Components.Overlays;
|
||||
|
||||
/// <summary>
|
||||
/// Event arguments for context menu.
|
||||
/// </summary>
|
||||
public class SbContextMenuEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// X coordinate of the click.
|
||||
/// </summary>
|
||||
public double X { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Y coordinate of the click.
|
||||
/// </summary>
|
||||
public double Y { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Set to true to cancel opening the menu.
|
||||
/// </summary>
|
||||
public bool Cancel { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
@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"
|
||||
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;
|
||||
|
||||
/// <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.
|
||||
/// If set, this runs instead of the default close behavior, so the parent can e.g. call its own Hide() to keep @bind-Open in sync.
|
||||
/// </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;
|
||||
if (Open)
|
||||
{
|
||||
await ShowAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
await HideAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 HandleCloseButtonClick()
|
||||
{
|
||||
if (OnCloseButtonClick.HasDelegate)
|
||||
{
|
||||
await OnCloseButtonClick.InvokeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
await CloseAsync(SbDialogCloseReason.CloseButton);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CloseAsync(SbDialogCloseReason reason)
|
||||
{
|
||||
_isOpen = false;
|
||||
await HideAsync();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// Cleanup if needed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace SufiChain.SufiBlazor.Components.Overlays;
|
||||
|
||||
/// <summary>
|
||||
/// Reasons why a dialog was closed.
|
||||
/// </summary>
|
||||
public enum SbDialogCloseReason
|
||||
{
|
||||
/// <summary>User pressed escape key.</summary>
|
||||
Escape,
|
||||
|
||||
/// <summary>User clicked the backdrop.</summary>
|
||||
Backdrop,
|
||||
|
||||
/// <summary>User clicked the close button.</summary>
|
||||
CloseButton,
|
||||
|
||||
/// <summary>Dialog was closed programmatically.</summary>
|
||||
Programmatic
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace SufiChain.SufiBlazor.Components.Overlays;
|
||||
|
||||
/// <summary>
|
||||
/// Size options for dialogs.
|
||||
/// </summary>
|
||||
public enum SbDialogSize
|
||||
{
|
||||
/// <summary>Small dialog (max-width: 400px).</summary>
|
||||
Sm,
|
||||
|
||||
/// <summary>Medium dialog (max-width: 600px).</summary>
|
||||
Md,
|
||||
|
||||
/// <summary>Large dialog (max-width: 800px).</summary>
|
||||
Lg,
|
||||
|
||||
/// <summary>Extra large dialog (max-width: 1024px).</summary>
|
||||
Xl,
|
||||
|
||||
/// <summary>Full screen dialog.</summary>
|
||||
FullScreen
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
@namespace SufiChain.SufiBlazor.Components.Overlays
|
||||
@implements IAsyncDisposable
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject IStringLocalizer<SufiBlazorResource> L
|
||||
@inject IStringLocalizer<SufiBlazorResource> L
|
||||
|
||||
<dialog @ref="_dialogRef"
|
||||
class="@CssClass"
|
||||
style="@StyleValue"
|
||||
@onclick="HandleBackdropClick"
|
||||
@onkeydown="HandleKeyDown"
|
||||
@oncancel="HandleCancel"
|
||||
aria-modal="@(Modal ? "true" : null)"
|
||||
@attributes="AdditionalAttributes">
|
||||
<div class="sb-drawer__container" @onclick:stopPropagation="true">
|
||||
@if (ShowHeader)
|
||||
{
|
||||
<header class="sb-drawer__header">
|
||||
@if (Header != null)
|
||||
{
|
||||
@Header
|
||||
}
|
||||
else if (Title != null)
|
||||
{
|
||||
<h2 class="sb-drawer__title">@Title</h2>
|
||||
}
|
||||
<button type="button"
|
||||
class="sb-drawer__close-btn"
|
||||
@onclick="CloseAsync"
|
||||
aria-label="@L["Close"]">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</header>
|
||||
}
|
||||
<div class="sb-drawer__body">
|
||||
@ChildContent
|
||||
</div>
|
||||
@if (Footer != null)
|
||||
{
|
||||
<footer class="sb-drawer__footer">
|
||||
@Footer
|
||||
</footer>
|
||||
}
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@code {
|
||||
private ElementReference _dialogRef;
|
||||
private bool _isOpen;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the drawer is open.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool Open { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Callback when open state changes.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback<bool> OpenChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Drawer placement.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SbDrawerPlacement Placement { get; set; } = SbDrawerPlacement.Start;
|
||||
|
||||
/// <summary>
|
||||
/// Whether drawer is modal (has backdrop).
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool Modal { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Drawer title.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Custom header content.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment? Header { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Footer content.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment? Footer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Drawer body content.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Width for Start/End placement.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string Width { get; set; } = "320px";
|
||||
|
||||
/// <summary>
|
||||
/// Height for Top/Bottom placement.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string Height { get; set; } = "320px";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to close on Escape key.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool CloseOnEscape { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to close on backdrop click.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool CloseOnBackdropClick { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Additional CSS classes.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string? Class { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional 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;
|
||||
|
||||
private string CssClass
|
||||
{
|
||||
get
|
||||
{
|
||||
var classes = new List<string> { "sb-drawer" };
|
||||
classes.Add($"sb-drawer--{Placement.ToString().ToLowerInvariant()}");
|
||||
if (Modal)
|
||||
{
|
||||
classes.Add("sb-drawer--modal");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(Class))
|
||||
{
|
||||
classes.Add(Class);
|
||||
}
|
||||
return string.Join(" ", classes);
|
||||
}
|
||||
}
|
||||
|
||||
private string StyleValue
|
||||
{
|
||||
get
|
||||
{
|
||||
var styles = new List<string>();
|
||||
|
||||
if (Placement is SbDrawerPlacement.Start or SbDrawerPlacement.End)
|
||||
{
|
||||
styles.Add($"--sb-drawer-width: {Width}");
|
||||
}
|
||||
else
|
||||
{
|
||||
styles.Add($"--sb-drawer-height: {Height}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Style))
|
||||
{
|
||||
styles.Add(Style);
|
||||
}
|
||||
|
||||
return string.Join("; ", styles);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (Open != _isOpen)
|
||||
{
|
||||
_isOpen = Open;
|
||||
if (Open)
|
||||
{
|
||||
await ShowAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
await HideAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ShowAsync()
|
||||
{
|
||||
if (Modal)
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("SufiBlazor.dialog.showModal", _dialogRef);
|
||||
}
|
||||
else
|
||||
{
|
||||
// For non-modal, use show() - but we'll handle via CSS
|
||||
await JSRuntime.InvokeVoidAsync("SufiBlazor.dialog.showModal", _dialogRef);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HideAsync()
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("SufiBlazor.dialog.close", _dialogRef);
|
||||
}
|
||||
|
||||
private async Task CloseAsync()
|
||||
{
|
||||
await OpenChanged.InvokeAsync(false);
|
||||
}
|
||||
|
||||
private async Task HandleBackdropClick(MouseEventArgs args)
|
||||
{
|
||||
if (CloseOnBackdropClick)
|
||||
{
|
||||
await CloseAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleKeyDown(KeyboardEventArgs args)
|
||||
{
|
||||
if (args.Key == "Escape" && CloseOnEscape)
|
||||
{
|
||||
await CloseAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleCancel()
|
||||
{
|
||||
if (CloseOnEscape)
|
||||
{
|
||||
await CloseAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// Cleanup if needed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace SufiChain.SufiBlazor.Components.Overlays;
|
||||
|
||||
/// <summary>
|
||||
/// Placement options for drawers using logical Start/End for RTL compatibility.
|
||||
/// </summary>
|
||||
public enum SbDrawerPlacement
|
||||
{
|
||||
/// <summary>Left in LTR, Right in RTL.</summary>
|
||||
Start,
|
||||
|
||||
/// <summary>Right in LTR, Left in RTL.</summary>
|
||||
End,
|
||||
|
||||
/// <summary>Top of the screen.</summary>
|
||||
Top,
|
||||
|
||||
/// <summary>Bottom of the screen.</summary>
|
||||
Bottom
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
@namespace SufiChain.SufiBlazor.Components.Overlays
|
||||
@implements IDisposable
|
||||
@inject IJSRuntime JSRuntime
|
||||
|
||||
<div class="sb-menu-anchor" @ref="_anchorRef">
|
||||
<div @onkeydown="HandleAnchorKeyDown">
|
||||
@AnchorContent
|
||||
</div>
|
||||
@if (Open)
|
||||
{
|
||||
<div class="@MenuCssClass"
|
||||
role="menu"
|
||||
@onkeydown="HandleMenuKeyDown"
|
||||
@onclick:stopPropagation="true"
|
||||
tabindex="-1"
|
||||
@ref="_menuRef">
|
||||
<CascadingValue Value="this">
|
||||
@ChildContent
|
||||
</CascadingValue>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private ElementReference _anchorRef;
|
||||
private ElementReference _menuRef;
|
||||
private DotNetObjectReference<SbMenu>? _dotNetRef;
|
||||
private int _focusedIndex = -1;
|
||||
private List<SbMenuItem> _items = new();
|
||||
|
||||
/// <summary>
|
||||
/// Whether the menu is open.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool Open { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Callback when open state changes.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback<bool> OpenChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Content that triggers the menu.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment? AnchorContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Menu items.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Menu placement.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SbPlacement Placement { get; set; } = SbPlacement.BottomStart;
|
||||
|
||||
/// <summary>
|
||||
/// Close when clicking a menu item.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool CloseOnItemClick { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Additional CSS classes.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string? Class { get; set; }
|
||||
|
||||
private string MenuCssClass
|
||||
{
|
||||
get
|
||||
{
|
||||
var classes = new List<string> { "sb-menu" };
|
||||
classes.Add($"sb-menu--{Placement.ToCssClass()}");
|
||||
if (!string.IsNullOrWhiteSpace(Class))
|
||||
{
|
||||
classes.Add(Class);
|
||||
}
|
||||
return string.Join(" ", classes);
|
||||
}
|
||||
}
|
||||
|
||||
internal void RegisterItem(SbMenuItem item)
|
||||
{
|
||||
if (!_items.Contains(item))
|
||||
{
|
||||
_items.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
internal void UnregisterItem(SbMenuItem item)
|
||||
{
|
||||
_items.Remove(item);
|
||||
}
|
||||
|
||||
internal async Task OnItemClickAsync()
|
||||
{
|
||||
if (CloseOnItemClick)
|
||||
{
|
||||
await CloseAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Toggle()
|
||||
{
|
||||
await OpenChanged.InvokeAsync(!Open);
|
||||
}
|
||||
|
||||
private async Task CloseAsync()
|
||||
{
|
||||
_focusedIndex = -1;
|
||||
await OpenChanged.InvokeAsync(false);
|
||||
}
|
||||
|
||||
private async Task HandleAnchorKeyDown(KeyboardEventArgs args)
|
||||
{
|
||||
if (args.Key is "Enter" or " " or "ArrowDown")
|
||||
{
|
||||
await OpenChanged.InvokeAsync(true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleMenuKeyDown(KeyboardEventArgs args)
|
||||
{
|
||||
var enabledItems = _items.Where(i => !i.Disabled).ToList();
|
||||
if (enabledItems.Count == 0) return;
|
||||
|
||||
switch (args.Key)
|
||||
{
|
||||
case "ArrowDown":
|
||||
_focusedIndex = (_focusedIndex + 1) % enabledItems.Count;
|
||||
await FocusItemAsync(_focusedIndex);
|
||||
break;
|
||||
|
||||
case "ArrowUp":
|
||||
_focusedIndex = _focusedIndex <= 0 ? enabledItems.Count - 1 : _focusedIndex - 1;
|
||||
await FocusItemAsync(_focusedIndex);
|
||||
break;
|
||||
|
||||
case "Home":
|
||||
_focusedIndex = 0;
|
||||
await FocusItemAsync(_focusedIndex);
|
||||
break;
|
||||
|
||||
case "End":
|
||||
_focusedIndex = enabledItems.Count - 1;
|
||||
await FocusItemAsync(_focusedIndex);
|
||||
break;
|
||||
|
||||
case "Escape":
|
||||
await CloseAsync();
|
||||
break;
|
||||
|
||||
case "Tab":
|
||||
await CloseAsync();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FocusItemAsync(int index)
|
||||
{
|
||||
var enabledItems = _items.Where(i => !i.Disabled).ToList();
|
||||
if (index >= 0 && index < enabledItems.Count)
|
||||
{
|
||||
await enabledItems[index].FocusAsync();
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
_dotNetRef = DotNetObjectReference.Create(this);
|
||||
}
|
||||
|
||||
if (Open)
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("SufiBlazor.clickAway.register", _anchorRef, _dotNetRef, "OnClickAway");
|
||||
}
|
||||
else
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("SufiBlazor.clickAway.unregister", _anchorRef);
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnClickAway()
|
||||
{
|
||||
if (Open)
|
||||
{
|
||||
await CloseAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dotNetRef?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
@namespace SufiChain.SufiBlazor.Components.Overlays
|
||||
@implements IDisposable
|
||||
@inject IJSRuntime JSRuntime
|
||||
|
||||
<button type="button"
|
||||
class="@CssClass"
|
||||
role="menuitem"
|
||||
disabled="@Disabled"
|
||||
tabindex="@(Disabled ? -1 : 0)"
|
||||
@ref="_elementRef"
|
||||
@onclick="HandleClick"
|
||||
@onkeydown="HandleKeyDown">
|
||||
@if (Icon != null)
|
||||
{
|
||||
<span class="sb-menu-item__icon">@Icon</span>
|
||||
}
|
||||
<span class="sb-menu-item__text">
|
||||
@if (ChildContent != null)
|
||||
{
|
||||
@ChildContent
|
||||
}
|
||||
else
|
||||
{
|
||||
@Text
|
||||
}
|
||||
</span>
|
||||
@if (!string.IsNullOrEmpty(Shortcut))
|
||||
{
|
||||
<span class="sb-menu-item__shortcut">@Shortcut</span>
|
||||
}
|
||||
</button>
|
||||
|
||||
@code {
|
||||
private ElementReference _elementRef;
|
||||
|
||||
[CascadingParameter]
|
||||
private SbMenu? ParentMenu { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Menu item text.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string? Text { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Menu item content (replaces Text).
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Icon content.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment? Icon { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Keyboard shortcut hint.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string? Shortcut { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the item is disabled.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool Disabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Click handler.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback OnClick { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional CSS classes.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string? Class { get; set; }
|
||||
|
||||
private string CssClass
|
||||
{
|
||||
get
|
||||
{
|
||||
var classes = new List<string> { "sb-menu-item" };
|
||||
if (Disabled)
|
||||
{
|
||||
classes.Add("sb-menu-item--disabled");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(Class))
|
||||
{
|
||||
classes.Add(Class);
|
||||
}
|
||||
return string.Join(" ", classes);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
ParentMenu?.RegisterItem(this);
|
||||
}
|
||||
|
||||
private async Task HandleClick()
|
||||
{
|
||||
if (!Disabled)
|
||||
{
|
||||
await OnClick.InvokeAsync();
|
||||
if (ParentMenu != null)
|
||||
{
|
||||
await ParentMenu.OnItemClickAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleKeyDown(KeyboardEventArgs args)
|
||||
{
|
||||
if (args.Key is "Enter" or " " && !Disabled)
|
||||
{
|
||||
await HandleClick();
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task FocusAsync()
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("SufiBlazor.focus.set", _elementRef);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ParentMenu?.UnregisterItem(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace SufiChain.SufiBlazor.Components.Overlays;
|
||||
|
||||
/// <summary>
|
||||
/// Placement options for positioned overlays (popovers, tooltips, menus).
|
||||
/// Uses logical Start/End naming for RTL/LTR compatibility.
|
||||
/// </summary>
|
||||
public enum SbPlacement
|
||||
{
|
||||
/// <summary>Top center, aligned to start.</summary>
|
||||
TopStart,
|
||||
|
||||
/// <summary>Top center.</summary>
|
||||
Top,
|
||||
|
||||
/// <summary>Top center, aligned to end.</summary>
|
||||
TopEnd,
|
||||
|
||||
/// <summary>Right side (or left in RTL), aligned to start.</summary>
|
||||
EndStart,
|
||||
|
||||
/// <summary>Right side (or left in RTL), centered.</summary>
|
||||
End,
|
||||
|
||||
/// <summary>Right side (or left in RTL), aligned to end.</summary>
|
||||
EndEnd,
|
||||
|
||||
/// <summary>Bottom center, aligned to start.</summary>
|
||||
BottomStart,
|
||||
|
||||
/// <summary>Bottom center.</summary>
|
||||
Bottom,
|
||||
|
||||
/// <summary>Bottom center, aligned to end.</summary>
|
||||
BottomEnd,
|
||||
|
||||
/// <summary>Left side (or right in RTL), aligned to start.</summary>
|
||||
StartStart,
|
||||
|
||||
/// <summary>Left side (or right in RTL), centered.</summary>
|
||||
Start,
|
||||
|
||||
/// <summary>Left side (or right in RTL), aligned to end.</summary>
|
||||
StartEnd
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for SbPlacement.
|
||||
/// </summary>
|
||||
public static class SbPlacementExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts placement to CSS class suffix.
|
||||
/// </summary>
|
||||
public static string ToCssClass(this SbPlacement placement) => placement switch
|
||||
{
|
||||
SbPlacement.TopStart => "top-start",
|
||||
SbPlacement.Top => "top",
|
||||
SbPlacement.TopEnd => "top-end",
|
||||
SbPlacement.EndStart => "end-start",
|
||||
SbPlacement.End => "end",
|
||||
SbPlacement.EndEnd => "end-end",
|
||||
SbPlacement.BottomStart => "bottom-start",
|
||||
SbPlacement.Bottom => "bottom",
|
||||
SbPlacement.BottomEnd => "bottom-end",
|
||||
SbPlacement.StartStart => "start-start",
|
||||
SbPlacement.Start => "start",
|
||||
SbPlacement.StartEnd => "start-end",
|
||||
_ => "bottom"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
@namespace SufiChain.SufiBlazor.Components.Overlays
|
||||
@implements IDisposable
|
||||
@inject IJSRuntime JSRuntime
|
||||
|
||||
<div class="sb-popover-anchor" @ref="_anchorRef">
|
||||
@AnchorContent
|
||||
@if (Open)
|
||||
{
|
||||
<div class="@PopoverCssClass"
|
||||
style="@PopoverStyle"
|
||||
@ref="_popoverRef"
|
||||
@onclick:stopPropagation="true"
|
||||
@attributes="AdditionalAttributes">
|
||||
@ChildContent
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private ElementReference _anchorRef;
|
||||
private ElementReference _popoverRef;
|
||||
private DotNetObjectReference<SbPopover>? _dotNetRef;
|
||||
private bool _flipUp;
|
||||
private int _constrainShiftX;
|
||||
private int _constrainShiftY;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the popover is open.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool Open { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Callback when open state changes.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback<bool> OpenChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Content that triggers the popover.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment? AnchorContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Popover content.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Popover placement.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SbPlacement Placement { get; set; } = SbPlacement.Bottom;
|
||||
|
||||
/// <summary>
|
||||
/// Offset from anchor in pixels.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public int Offset { get; set; } = 8;
|
||||
|
||||
/// <summary>
|
||||
/// When true, popover is shifted so it stays within the viewport (avoids overflow when anchor is near edges).
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool ConstrainToViewport { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When true, popover flips above the anchor when there isn't enough space below (e.g. near bottom of viewport).
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool FlipBehavior { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Estimated popover height in pixels used to decide whether to flip. Used when FlipBehavior is true.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public int FlipPreferredHeight { get; set; } = 300;
|
||||
|
||||
/// <summary>
|
||||
/// Close when clicking outside.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool CloseOnClickAway { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Close on Escape key.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool CloseOnEscape { get; set; } = true;
|
||||
|
||||
/// <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 SbPlacement EffectivePlacement
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!_flipUp) return Placement;
|
||||
return Placement switch
|
||||
{
|
||||
SbPlacement.Bottom => SbPlacement.Top,
|
||||
SbPlacement.BottomStart => SbPlacement.TopStart,
|
||||
SbPlacement.BottomEnd => SbPlacement.TopEnd,
|
||||
SbPlacement.Top => SbPlacement.Bottom,
|
||||
SbPlacement.TopStart => SbPlacement.BottomStart,
|
||||
SbPlacement.TopEnd => SbPlacement.BottomEnd,
|
||||
_ => Placement
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private string PopoverCssClass
|
||||
{
|
||||
get
|
||||
{
|
||||
var classes = new List<string> { "sb-popover" };
|
||||
classes.Add($"sb-popover--{EffectivePlacement.ToCssClass()}");
|
||||
if (!string.IsNullOrWhiteSpace(Class))
|
||||
{
|
||||
classes.Add(Class);
|
||||
}
|
||||
return string.Join(" ", classes);
|
||||
}
|
||||
}
|
||||
|
||||
private string PopoverStyle
|
||||
{
|
||||
get
|
||||
{
|
||||
var parts = new List<string>();
|
||||
parts.Add($"--sb-popover-shift-x: {_constrainShiftX}px");
|
||||
parts.Add($"--sb-popover-shift-y: {_constrainShiftY}px");
|
||||
if (!string.IsNullOrWhiteSpace(Style))
|
||||
{
|
||||
parts.Add(Style);
|
||||
}
|
||||
return string.Join("; ", parts);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
_dotNetRef = DotNetObjectReference.Create(this);
|
||||
}
|
||||
|
||||
if (Open)
|
||||
{
|
||||
if (FlipBehavior && IsBottomPlacement(Placement))
|
||||
{
|
||||
_flipUp = await JSRuntime.InvokeAsync<bool>("SufiBlazor.select.shouldFlipUp", _anchorRef, FlipPreferredHeight);
|
||||
}
|
||||
else
|
||||
{
|
||||
_flipUp = false;
|
||||
}
|
||||
|
||||
if (ConstrainToViewport)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await JSRuntime.InvokeAsync<PopoverConstraintResult>("SufiBlazor.popover.constrainToViewport", _anchorRef, _popoverRef, Offset);
|
||||
_constrainShiftX = result?.ShiftX ?? 0;
|
||||
_constrainShiftY = result?.ShiftY ?? 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_constrainShiftX = 0;
|
||||
_constrainShiftY = 0;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_constrainShiftX = 0;
|
||||
_constrainShiftY = 0;
|
||||
}
|
||||
|
||||
if (CloseOnClickAway)
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("SufiBlazor.clickAway.register", _anchorRef, _dotNetRef, "OnClickAway");
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
else
|
||||
{
|
||||
_flipUp = false;
|
||||
_constrainShiftX = 0;
|
||||
_constrainShiftY = 0;
|
||||
await JSRuntime.InvokeVoidAsync("SufiBlazor.clickAway.unregister", _anchorRef);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsBottomPlacement(SbPlacement placement) =>
|
||||
placement is SbPlacement.Bottom or SbPlacement.BottomStart or SbPlacement.BottomEnd;
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnClickAway()
|
||||
{
|
||||
if (CloseOnClickAway && Open)
|
||||
{
|
||||
await OpenChanged.InvokeAsync(false);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dotNetRef?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
@namespace SufiChain.SufiBlazor.Components.Overlays
|
||||
|
||||
<span class="sb-tooltip-wrapper"
|
||||
@onmouseenter="Show"
|
||||
@onmouseleave="Hide"
|
||||
@onfocus="Show"
|
||||
@onblur="Hide"
|
||||
@attributes="AdditionalAttributes">
|
||||
@ChildContent
|
||||
@if (_isVisible)
|
||||
{
|
||||
<span class="@TooltipCssClass"
|
||||
role="tooltip"
|
||||
id="@_tooltipId"
|
||||
aria-hidden="@(!_isVisible)">
|
||||
@if (Content != null)
|
||||
{
|
||||
@Content
|
||||
}
|
||||
else
|
||||
{
|
||||
@Text
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
|
||||
@code {
|
||||
private bool _isVisible;
|
||||
private readonly string _tooltipId = $"sb-tooltip-{Guid.NewGuid():N}";
|
||||
private System.Timers.Timer? _showTimer;
|
||||
private System.Timers.Timer? _hideTimer;
|
||||
|
||||
/// <summary>
|
||||
/// Content to wrap with tooltip.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Simple text tooltip.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string? Text { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rich tooltip content.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment? Content { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Tooltip placement.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public SbPlacement Placement { get; set; } = SbPlacement.Top;
|
||||
|
||||
/// <summary>
|
||||
/// Delay before showing (ms).
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public int Delay { get; set; } = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Delay before hiding (ms).
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public int HideDelay { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum width of tooltip.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string? MaxWidth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional HTML attributes for the wrapper.
|
||||
/// </summary>
|
||||
[Parameter(CaptureUnmatchedValues = true)]
|
||||
public Dictionary<string, object>? AdditionalAttributes { get; set; }
|
||||
|
||||
private string TooltipCssClass
|
||||
{
|
||||
get
|
||||
{
|
||||
var classes = new List<string> { "sb-tooltip" };
|
||||
classes.Add($"sb-tooltip--{Placement.ToCssClass()}");
|
||||
if (Content != null)
|
||||
{
|
||||
classes.Add("sb-tooltip--rich");
|
||||
}
|
||||
return string.Join(" ", classes);
|
||||
}
|
||||
}
|
||||
|
||||
private void Show()
|
||||
{
|
||||
_hideTimer?.Stop();
|
||||
|
||||
if (Delay > 0)
|
||||
{
|
||||
_showTimer = new System.Timers.Timer(Delay);
|
||||
_showTimer.Elapsed += (_, _) =>
|
||||
{
|
||||
_isVisible = true;
|
||||
InvokeAsync(StateHasChanged);
|
||||
_showTimer?.Dispose();
|
||||
};
|
||||
_showTimer.AutoReset = false;
|
||||
_showTimer.Start();
|
||||
}
|
||||
else
|
||||
{
|
||||
_isVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void Hide()
|
||||
{
|
||||
_showTimer?.Stop();
|
||||
|
||||
if (HideDelay > 0)
|
||||
{
|
||||
_hideTimer = new System.Timers.Timer(HideDelay);
|
||||
_hideTimer.Elapsed += (_, _) =>
|
||||
{
|
||||
_isVisible = false;
|
||||
InvokeAsync(StateHasChanged);
|
||||
_hideTimer?.Dispose();
|
||||
};
|
||||
_hideTimer.AutoReset = false;
|
||||
_hideTimer.Start();
|
||||
}
|
||||
else
|
||||
{
|
||||
_isVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_showTimer?.Dispose();
|
||||
_hideTimer?.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user