first commit
Release NuGet Packages / pack-and-push (release) Failing after 10m17s

This commit is contained in:
2026-05-21 11:20:43 +03:30
commit 3ff35dedb5
536 changed files with 94352 additions and 0 deletions
@@ -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();
}
}