summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJake Mannens <jake@asger.xyz>2026-06-11 00:57:51 +1000
committerJake Mannens <jake@asger.xyz>2026-06-11 01:40:57 +1000
commit3263576943a491bcad3aff6c18c166fb1c874619 (patch)
tree750d5ac7234a6b3da622cd1095a5bf1bca084956
parenta547945940de4ee5638a761053797cbbe1e0302f (diff)
Added Button Razor component
-rw-r--r--Pages/Component/Button.razor136
-rw-r--r--Pages/Component/Button.razor.css79
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); }
+}