This commit is contained in:
2026-06-04 17:08:09 -04:00
parent 3b165de7a9
commit 9bd7620a3b
4 changed files with 283 additions and 6 deletions
@@ -0,0 +1,138 @@
@implements IDisposable
<div class="cooldown-wrap" style="--cooldown-size: @(Size)px">
<button class="cooldown-btn"
disabled="@_isCooldown"
@onclick="HandleClick"
@onclick:preventDefault="_isCooldown">
<span class="cooldown-btn-text">@ChildContent</span>
</button>
@if (_isCooldown)
{
<div class="cooldown-overlay"
style="mask-image: conic-gradient(transparent 0deg, transparent @(_elapsedAngle)deg, #000 @(_elapsedAngle)deg, #000 360deg);
-webkit-mask-image: conic-gradient(transparent 0deg, transparent @(_elapsedAngle)deg, #000 @(_elapsedAngle)deg, #000 360deg);">
<span class="cooldown-label">@_remainingSeconds</span>
</div>
}
</div>
<style>
.cooldown-wrap {
position: relative;
display: inline-flex;
width: var(--cooldown-size, 120px);
height: var(--cooldown-size, 120px);
}
.cooldown-btn {
width: 100%;
height: 100%;
border: 2px solid var(--primary);
border-radius: 12px;
background: var(--paper);
color: var(--text-primary, #eee);
font-weight: 700;
font-size: 0.9rem;
cursor: pointer;
padding: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s, border-color 0.15s;
line-height: 1.3;
font-family: inherit;
}
.cooldown-btn:hover:not(:disabled) {
background: var(--paper-hover);
border-color: var(--primary-hover);
}
.cooldown-btn:active:not(:disabled) {
transform: scale(0.97);
}
.cooldown-btn:disabled {
opacity: 0;
cursor: default;
}
.cooldown-btn-text {
pointer-events: none;
}
.cooldown-overlay {
position: absolute;
inset: 0;
border-radius: 12px;
background: rgba(22, 22, 24, 0.82);
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
user-select: none;
}
.cooldown-label {
font-size: 1.6rem;
font-weight: 900;
color: #999;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
pointer-events: none;
}
</style>
@code {
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public EventCallback OnClick { get; set; }
[Parameter] public int CooldownSeconds { get; set; } = 12;
[Parameter] public int Size { get; set; } = 120;
private bool _isCooldown;
private int _elapsedAngle;
private int _remainingSeconds;
private DateTime _startTime;
private System.Timers.Timer? _timer;
private async Task HandleClick()
{
if (_isCooldown) return;
await OnClick.InvokeAsync(null);
StartCooldown();
}
private void StartCooldown()
{
_isCooldown = true;
_startTime = DateTime.UtcNow;
_elapsedAngle = 0;
_remainingSeconds = CooldownSeconds;
_timer = new System.Timers.Timer(33);
_timer.Elapsed += OnTick;
_timer.AutoReset = true;
_timer.Enabled = true;
}
private void OnTick(object? sender, System.Timers.ElapsedEventArgs e)
{
var elapsed = (DateTime.UtcNow - _startTime).TotalSeconds;
if (elapsed >= CooldownSeconds)
{
_isCooldown = false;
_timer?.Stop();
_timer?.Dispose();
_timer = null;
InvokeAsync(StateHasChanged);
return;
}
_elapsedAngle = (int)(elapsed / CooldownSeconds * 360);
_remainingSeconds = CooldownSeconds - (int)elapsed;
InvokeAsync(StateHasChanged);
}
public void Dispose()
{
if (_timer != null)
{
_timer.Stop();
_timer.Dispose();
_timer = null;
}
}
}
+29 -1
View File
@@ -14,6 +14,15 @@
<div>
Refer to various aspects of "IMMORTAL: Gates of Pyre" from this external reference!
</div>
<div class="cooldown-demo">
<div class="cooldown-demo-label">Cooldown Demo</div>
<CooldownButtonComponent CooldownSeconds="12"
Size="120"
OnClick="OnCooldownClick">
Click Me
</CooldownButtonComponent>
</div>
</div>
</PaperComponent>
@@ -42,6 +51,18 @@
.mainContainer {
padding-bottom: 32px;
}
.cooldown-demo {
margin-top: 32px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.cooldown-demo-label {
font-size: 1rem;
font-weight: 600;
color: var(--text-secondary, #aaa);
}
.mainTitle {
font-size: 2.2rem;
@@ -62,4 +83,11 @@
grid-template-columns: 1fr;
}
}
</style>
</style>
@code {
private Task OnCooldownClick()
{
return Task.CompletedTask;
}
}
+5 -5
View File
@@ -42,12 +42,12 @@
"state": {
"type": "markdown",
"state": {
"file": "Build Calculator CmdLine.md",
"file": "Feature Proposals.md",
"mode": "source",
"source": false
},
"icon": "lucide-file",
"title": "Build Calculator CmdLine"
"title": "Feature Proposals"
}
}
],
@@ -225,10 +225,11 @@
},
"active": "68e1ba2b54081b9a",
"lastOpenFiles": [
"Device MAUI Pages Setup.md",
"cooldown-button.md",
"Build Calculator CmdLine.md",
"Feature Proposals.md",
"_Tasks Kanban.base",
"Device MAUI Pages Setup.md",
"Feature Proposals.md",
"Tasks/Worker Income UI and Tests.md",
"Tasks/Update the Reference Tables with Telerik.md",
"Tasks/WebAssembly back to Azure.md",
@@ -251,7 +252,6 @@
"Tasks/Highest Alloy and Ether Tests.md",
"Tasks/Get AI to Add easy Test Tasks.md",
"Tasks/Helper Tutorial Info Improvements.md",
"Tasks/Fix Entity Recursion Error - Parent.md",
"Tasks",
"Images/Pasted image 20260601093510.png",
"Images/Pasted image 20260601083333.png",
+111
View File
@@ -0,0 +1,111 @@
# Cooldown Button Component
A square Blazor button with a 12-second cooldown animation: when clicked, the button greys out and a circular transparency wedge dials open clockwise, progressively revealing the normal button state underneath.
---
## Visual Design
The button has two visual states:
**Idle state** A solid square button with the project's standard `--paper` background and `--primary` border. Hover inverts or brightens the paper. Click triggers a brief scale-down.
**Cooldown state** The native button fades to `opacity: 0` while an absolutely-positioned overlay covers it. The overlay uses a `conic-gradient` CSS mask to create a "dialling open" effect. A number in the centre shows the remaining seconds.
---
## Core Technique: Conic-Gradient Mask
The cooldown reveal is accomplished with a **conic-gradient mask** applied to the overlay `<div>`:
```
mask-image: conic-gradient(
transparent 0deg,
transparent {angle}deg,
#000 {angle}deg,
#000 360deg
);
```
- **`transparent`** lets the button underneath show through (revealed area)
- **`#000` (black)** fully masks the overlay, making it visible (greyed-out area)
At `angle = 0deg`, transparent covers nothing and the mask is entirely black → the overlay is fully opaque (button completely greyed out).
At `angle = 360deg`, transparent covers the full circle and black covers nothing → the overlay is fully transparent (button completely visible).
The angle animates linearly from 0 to 360 over the cooldown duration. Because `conic-gradient` starts at the 12 o'clock position and sweeps clockwise, the reveal begins at the top of the button and rotates around, like a clock hand or a dial opening.
---
## Implementation Architecture
### Component Parameters
| Parameter | Type | Default | Description |
|------------------|-------------------|---------|------------------------------------|
| `ChildContent` | `RenderFragment` | `null` | Text or content inside the button |
| `OnClick` | `EventCallback` | — | Fired when the button is clicked |
| `CooldownSeconds`| `int` | `12` | Duration of the cooldown in seconds|
| `Size` | `int` | `120` | Width and height in pixels (square)|
### Timer Loop
A `System.Timers.Timer` fires every ~33ms (≈30fps) during the cooldown:
```
OnTick:
elapsed = UtcNow - startTime
if elapsed >= CooldownSeconds → end cooldown, dispose timer
_elapsedAngle = (elapsed / CooldownSeconds) * 360
_remainingSeconds = CooldownSeconds - (int)elapsed
InvokeAsync(StateHasChanged)
```
On each tick, `_elapsedAngle` is written into the overlay's inline `style` attribute, causing Blazor to re-render the `mask-image`. The timer is disposed in `Dispose()` to prevent leaks.
### Disposal
The component implements `IDisposable` to clean up the timer when the component is removed from the render tree. This follows the same pattern used by `SearchDialogComponent` and `BuildChartComponent` elsewhere in the codebase.
---
## CSS Masking Details
Two vendor-prefixed properties are set to ensure cross-browser support:
```
mask-image: conic-gradient(...);
-webkit-mask-image: conic-gradient(...);
```
The overlay uses `pointer-events: none` and `user-select: none` so that interaction passes through to the button underneath (which is disabled and transparent).
An `rgba(22, 22, 24, 0.82)` semi-transparent background on the overlay produces the greyed-out appearance. The mask controls *where* this background is visible.
---
## Usage on the Home Page
The component is added to `Pages/Pages/Home/HomePage.razor` inside the first `PaperComponent`:
```razor
<CooldownButtonComponent CooldownSeconds="12"
Size="120"
OnClick="OnCooldownClick">
Click Me
</CooldownButtonComponent>
```
The `OnCooldownClick` handler in the page's `@code` block currently returns `Task.CompletedTask` (a no-op). This is the extension point where real work (e.g. triggering a game action, calling an API, showing a toast) would go.
---
## Adapting the Component
- **Change cooldown duration** set `CooldownSeconds` to any positive integer.
- **Change button size** set `Size` to any pixel dimension (button remains square).
- **Custom content** pass any Blazor markup as `ChildContent` (text, icons, spinners).
- **Handle the click** attach a handler to `OnClick` that returns `Task` or `void`.
The `--cooldown-size` CSS custom property is set inline on the wrapper so that the label, overlay, and button all scale together.