diff options
| author | Jake Mannens <jake@asger.xyz> | 2026-06-11 00:57:51 +1000 |
|---|---|---|
| committer | Jake Mannens <jake@asger.xyz> | 2026-06-11 01:40:57 +1000 |
| commit | 3263576943a491bcad3aff6c18c166fb1c874619 (patch) | |
| tree | 750d5ac7234a6b3da622cd1095a5bf1bca084956 /Pages/Component | |
| parent | a547945940de4ee5638a761053797cbbe1e0302f (diff) | |
Added Button Razor component
Diffstat (limited to 'Pages/Component')
| -rw-r--r-- | Pages/Component/Button.razor | 136 | ||||
| -rw-r--r-- | Pages/Component/Button.razor.css | 79 |
2 files changed, 215 insertions, 0 deletions
diff --git a/Pages/Component/Button.razor b/Pages/Component/Button.razor new file mode 100644 index 0000000..4212e50 --- /dev/null +++ b/Pages/Component/Button.razor @@ -0,0 +1,136 @@ +@using System.Timers +@implements IDisposable +@inject IJSRuntime js + +<button + @attributes=Attributes + @onclick=Trigger + @ref=button> + + @if(isLoading) { + <svg + width="15" + height="15" + viewBox="0 0 1000 1000" + fill="none" + class="loading"> + + <path d=" + M 500 0 + A 500 500 0 1 1 0 500 + A 75 75 0 1 1 150 500 + A 350 350 0 1 0 500 150 + A 75 75 0 1 1 500 0 + Z" + fill="currentColor"> + </path> + </svg> + } else if(IconUrl is not null) { + <img src=@IconUrl /> + } + + @ChildContent +</button> + +@code { + [Parameter] + public RenderFragment? ChildContent { get; set; } + + [Parameter] + public Func<Task> OnClick { get; set; } + + [Parameter] + public Color BackgroundColor { get; set; } = Color.ButtonSecondary; + + [Parameter] + public string? IconUrl { get; set; } + + [Parameter] + public char? ShortcutKey { + get => shortcutKey; + set { + if(value is not null && !char.IsLetter((char) value)) + throw new System.ArgumentException("Cannot assign a non-letter to a shortcut"); + shortcutKey = value is null ? null : char.ToLower((char) value); + } + } + + [Parameter] + public int? FontSize { get; set; } + + private Dictionary<string, object?> Attributes => new() { + ["data-keyboard-shortcut"] = ShortcutKey, + ["disabled"] = isLoading, + ["style"] = CssStyle + }; + + private string CssStyle => string.Join("", [ + $"--col-pri:{BackgroundColor.CssString};", + FontSize is not null ? $"font-size:{FontSize}pt;" : null + ]); + + private ElementReference button; + + private Timer loadingTimer; + + private char? shortcutKey; + + private object triggerLock = new(); + private bool isRunning = false; + private bool isLoading = false; + + protected override void OnInitialized() { + loadingTimer = new(50) { + AutoReset = false + }; + loadingTimer.Elapsed += TimerElapsed; + } + + // Most button-click actions (e.g. triggering dialogs, page + // navigation, etc) complete very quickly (under 50ms). + // Triggering a render cycle to show a loading animation is + // usually pointless as doing so would waste resources and + // appear less seemless to the user who will see brief + // flickering while the browser quickly recalculates it's + // layout twice. To that end, a timer is used to only render + // the loading animation on the button if the delegate task + // takes longer than 50ms + private void TimerElapsed(object? sender, EventArgs e) { + lock(triggerLock) { + if(isRunning) + isLoading = true; + InvokeAsync(() => StateHasChanged()); + } + } + + private async Task Trigger() { + lock(triggerLock) { + if(isRunning) + return; + isRunning = true; + loadingTimer.Start(); + } + + bool error = false; + + try { + await Task.Run(OnClick); + } catch { + error = true; + } finally { + lock(triggerLock) { + loadingTimer.Stop(); + isRunning = false; + isLoading = false; + } + } + + await InvokeAsync(() => StateHasChanged()); + + if(error) + await js.InvokeVoidAsync("triggerButtonError", button); + } + + public void Dispose() => + loadingTimer.Dispose(); +} diff --git a/Pages/Component/Button.razor.css b/Pages/Component/Button.razor.css new file mode 100644 index 0000000..5254030 --- /dev/null +++ b/Pages/Component/Button.razor.css @@ -0,0 +1,79 @@ +button { + align-items: center; + background: var(--col-pri); + border-radius: 10px; + border: none; + box-sizing: border-box; + color: #fff; + cursor: pointer; + display: flex; + height: 30px; + margin: 10px 5px 0 5px; + padding: 0 9px 0 9px; + user-select: none; +} + +button > img, button > svg { + height: 15px; + margin-right: 5px; + width: 15px; +} + +@media (hover: none) and (pointer: coarse) { + button > ::deep :not(:first-child) { + display: none; + } + + button > img, button > svg { + height: 20px; + margin-right: 0; + padding: 8px; + width: 20px; + } + + /* disable hotkey underlines on mobile devices */ + ::deep button > ::deep u { + text-decoration: none !important; + } +} + +@media(hover: hover) { + button:hover:not(:active) { + filter: brightness(1.2) saturate(0.5); + } +} + +button:active { + background: #fff; + color: var(--col-pri); +} + + +button:disabled { + color: var(--col-button-disabled) !important; + background: var(--col-button-disabled-bg) !important; +} + +button.error { + animation-iteration-count: 3; + animation-timing-function: linear; + animation: button-error 0.2s; +} + +button > svg.loading { + animation: loading-spin 1s linear infinite; + transform-origin: center; + will-change: transform; +} + +@keyframes button-error { + 0% { transform: translateX(0); } + 33% { transform: translateX(-5px); } + 66% { transform: translateX(+5px); } + 100% { transform: translateX(0); } +} + +@keyframes loading-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} |
