diff --git a/src/ASTRAIN.Client/Components/KebabMenu.razor b/src/ASTRAIN.Client/Components/KebabMenu.razor new file mode 100644 index 0000000..e20c937 --- /dev/null +++ b/src/ASTRAIN.Client/Components/KebabMenu.razor @@ -0,0 +1,94 @@ +@implements IAsyncDisposable +@inject IJSRuntime JS + +
+ + @if (_isOpen) + { + + } +
+ +@code { + [Parameter] public string AriaLabel { get; set; } = "Menu"; + [Parameter] public RenderFragment ChildContent { get; set; } = default!; + + private bool _isOpen; + private ElementReference _menuRef; + private DotNetObjectReference? _dotNetRef; + private IJSObjectReference? _jsRef; + private readonly KebabMenuContext _context; + + public KebabMenu() + { + _context = new KebabMenuContext(Close); + } + + /// + /// Toggles the open state of the menu. + /// + private void Toggle() + { + _isOpen = !_isOpen; + } + + /// + /// Closes the menu if it is open. + /// + [JSInvokable] + public void Close() + { + if (!_isOpen) + { + return; + } + + _isOpen = false; + _ = InvokeAsync(StateHasChanged); + } + + /// + /// Called after the component has been rendered. Registers the JavaScript event handler on first render. + /// + /// True if this is the first render; otherwise, false. + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + { + return; + } + + _dotNetRef = DotNetObjectReference.Create(this); + _jsRef = await JS.InvokeAsync("kebabMenu.register", _menuRef, _dotNetRef); + } + + /// + /// Disposes the JavaScript object reference and event handler. + /// + public async ValueTask DisposeAsync() + { + if (_jsRef is not null) + { + await _jsRef.InvokeVoidAsync("dispose"); + await _jsRef.DisposeAsync(); + } + + _dotNetRef?.Dispose(); + } + + /// + /// Initializes a new instance of the KebabMenuContext class. + /// + /// The action to close the menu. + public sealed class KebabMenuContext + { + public KebabMenuContext(Action close) + { + Close = close; + } + + public Action Close { get; } + } +} diff --git a/src/ASTRAIN.Client/_Imports.razor b/src/ASTRAIN.Client/_Imports.razor index 16ace81..b2a7307 100644 --- a/src/ASTRAIN.Client/_Imports.razor +++ b/src/ASTRAIN.Client/_Imports.razor @@ -7,6 +7,7 @@ @using Microsoft.AspNetCore.Components.WebAssembly.Http @using Microsoft.JSInterop @using ASTRAIN.Client +@using ASTRAIN.Client.Components @using ASTRAIN.Client.Layout @using ASTRAIN.Client.Services @using ASTRAIN.Shared.Dtos diff --git a/src/ASTRAIN.Client/wwwroot/css/app.css b/src/ASTRAIN.Client/wwwroot/css/app.css index aba190b..a402999 100644 --- a/src/ASTRAIN.Client/wwwroot/css/app.css +++ b/src/ASTRAIN.Client/wwwroot/css/app.css @@ -154,6 +154,74 @@ p { 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 { display: flex; gap: 0.5rem; @@ -216,6 +284,8 @@ p { .main-content { flex: 1; padding: 1.5rem 1.25rem 6rem; + width: 100%; + margin: 0 auto; } .brand-header { @@ -305,7 +375,7 @@ p { @media (min-width: 768px) { .main-content { padding: 2rem 2.5rem 7rem; - max-width: 960px; + max-width: 720px; margin: 0 auto; } diff --git a/src/ASTRAIN.Client/wwwroot/index.html b/src/ASTRAIN.Client/wwwroot/index.html index 41ccd75..3568de7 100644 --- a/src/ASTRAIN.Client/wwwroot/index.html +++ b/src/ASTRAIN.Client/wwwroot/index.html @@ -30,6 +30,7 @@ Reload 🗙 + diff --git a/src/ASTRAIN.Client/wwwroot/js/kebabMenu.js b/src/ASTRAIN.Client/wwwroot/js/kebabMenu.js new file mode 100644 index 0000000..2887399 --- /dev/null +++ b/src/ASTRAIN.Client/wwwroot/js/kebabMenu.js @@ -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); + } + }; + } +};