Add KebabMenu component with JavaScript handler and styles for dropdown actions

This commit is contained in:
2026-02-04 21:47:08 +01:00
parent 65a13539e0
commit fd5abef3f6
5 changed files with 195 additions and 1 deletions

View File

@@ -0,0 +1,94 @@
@implements IAsyncDisposable
@inject IJSRuntime JS
<div class="kebab-menu" @ref="_menuRef">
<button class="kebab-button" type="button" @onclick="Toggle" aria-label="@AriaLabel" aria-haspopup="menu" aria-expanded="@_isOpen">⋮</button>
@if (_isOpen)
{
<div class="kebab-menu__items" role="menu">
@ChildContent(_context)
</div>
}
</div>
@code {
[Parameter] public string AriaLabel { get; set; } = "Menu";
[Parameter] public RenderFragment<KebabMenuContext> ChildContent { get; set; } = default!;
private bool _isOpen;
private ElementReference _menuRef;
private DotNetObjectReference<KebabMenu>? _dotNetRef;
private IJSObjectReference? _jsRef;
private readonly KebabMenuContext _context;
public KebabMenu()
{
_context = new KebabMenuContext(Close);
}
/// <summary>
/// Toggles the open state of the menu.
/// </summary>
private void Toggle()
{
_isOpen = !_isOpen;
}
/// <summary>
/// Closes the menu if it is open.
/// </summary>
[JSInvokable]
public void Close()
{
if (!_isOpen)
{
return;
}
_isOpen = false;
_ = InvokeAsync(StateHasChanged);
}
/// <summary>
/// Called after the component has been rendered. Registers the JavaScript event handler on first render.
/// </summary>
/// <param name="firstRender">True if this is the first render; otherwise, false.</param>
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
_dotNetRef = DotNetObjectReference.Create(this);
_jsRef = await JS.InvokeAsync<IJSObjectReference>("kebabMenu.register", _menuRef, _dotNetRef);
}
/// <summary>
/// Disposes the JavaScript object reference and event handler.
/// </summary>
public async ValueTask DisposeAsync()
{
if (_jsRef is not null)
{
await _jsRef.InvokeVoidAsync("dispose");
await _jsRef.DisposeAsync();
}
_dotNetRef?.Dispose();
}
/// <summary>
/// Initializes a new instance of the KebabMenuContext class.
/// </summary>
/// <param name="close">The action to close the menu.</param>
public sealed class KebabMenuContext
{
public KebabMenuContext(Action close)
{
Close = close;
}
public Action Close { get; }
}
}

View File

@@ -7,6 +7,7 @@
@using Microsoft.AspNetCore.Components.WebAssembly.Http @using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using ASTRAIN.Client @using ASTRAIN.Client
@using ASTRAIN.Client.Components
@using ASTRAIN.Client.Layout @using ASTRAIN.Client.Layout
@using ASTRAIN.Client.Services @using ASTRAIN.Client.Services
@using ASTRAIN.Shared.Dtos @using ASTRAIN.Shared.Dtos

View File

@@ -154,6 +154,74 @@ p {
flex-wrap: wrap; flex-wrap: wrap;
} }
.start-button {
width: 44px;
height: 44px;
padding: 0;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
}
.kebab-menu {
position: relative;
}
.kebab-button {
list-style: none;
background: transparent;
color: #ffffff;
border: 1px solid #3a3a3a;
border-radius: 12px;
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 1.4rem;
}
.kebab-button::-webkit-details-marker {
display: none;
}
.kebab-menu__items {
position: absolute;
right: 0;
top: calc(100% + 0.4rem);
min-width: 140px;
background: #151515;
border: 1px solid #2a2a2a;
border-radius: 12px;
padding: 0.35rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
z-index: 10;
}
.kebab-menu__item {
background: transparent;
color: #ffffff;
border: none;
border-radius: 10px;
text-align: left;
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 0.95rem;
}
.kebab-menu__item:hover {
background: #222222;
}
.kebab-menu__item.danger {
color: #ff6b6b;
}
.checkbox-row { .checkbox-row {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@@ -216,6 +284,8 @@ p {
.main-content { .main-content {
flex: 1; flex: 1;
padding: 1.5rem 1.25rem 6rem; padding: 1.5rem 1.25rem 6rem;
width: 100%;
margin: 0 auto;
} }
.brand-header { .brand-header {
@@ -305,7 +375,7 @@ p {
@media (min-width: 768px) { @media (min-width: 768px) {
.main-content { .main-content {
padding: 2rem 2.5rem 7rem; padding: 2rem 2.5rem 7rem;
max-width: 960px; max-width: 720px;
margin: 0 auto; margin: 0 auto;
} }

View File

@@ -30,6 +30,7 @@
<a href="." class="reload">Reload</a> <a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span> <span class="dismiss">🗙</span>
</div> </div>
<script src="js/kebabMenu.js"></script>
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script> <script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
</body> </body>

View File

@@ -0,0 +1,28 @@
window.kebabMenu = {
/**
* Registers the kebab menu event handler for closing on outside clicks.
* @param {HTMLElement} element - The menu element.
* @param {object} dotNetRef - The .NET object reference.
* @returns {object} An object with a dispose method.
*/
register: function (element, dotNetRef) {
if (!element) {
return null;
}
const handler = (event) => {
if (!element.contains(event.target)) {
dotNetRef.invokeMethodAsync('Close');
}
};
document.addEventListener('click', handler, true);
return {
// Disposes the event listener.
dispose: function () {
document.removeEventListener('click', handler, true);
}
};
}
};