Add a resend-code cooldown to the OTP screen
Angular · Angular · intermediate · greenfield
Adds the resend button with its cooldown as a small signal machine: the remaining seconds live in a signal, and an effect schedules one `setTimeout` per tick, cleaning it up via the effect's `onCleanup` hook. Uses the `output()` function API for the event, so there's no `@Output()` decorator or EventEmitter import. A visually-hidden status region announces once when resending is available again.
Requirements
- Build the standalone `ResendCodeComponent` for the OTP screen: a single button that requests a new code.
- Clicking the button emits a `resendRequested` event (the parent performs the actual API call) and starts a 30-second cooldown.
- During the cooldown the button is disabled and its text shows the remaining seconds (`Resend code in 12s`); afterwards it re-enables and reads `Resend code` again.
- The countdown ticks once per second; exact wall-clock precision is not required.
- The timer must not leak: navigating away from the OTP screen mid-countdown must cancel any pending timer.
- On first render the button is immediately enabled (no initial cooldown).
- Assistive tech must be told when resending becomes available again: announce a single status message when the cooldown ends (a persistent polite live region). Do **not** announce every tick — a per-second announcement is noise.
Files touched
- src/app/auth/resend-code.component.ts
--- src/app/auth/resend-code.component.ts
+import { Component, effect, output, signal } from '@angular/core';
+
+@Component({
+ selector: 'app-resend-code',
+ standalone: true,
+ template: `
+ <button type="button" (click)="resend()" [disabled]="cooldown() > 0">
+ @if (cooldown() > 0) {
+ Resend code in {{ cooldown() }}s
+ } @else {
+ Resend code
+ }
+ </button>
+ <span class="visually-hidden" role="status">
+ @if (requested() && cooldown() === 0) {
+ You can request a new code now.
+ }
+ </span>
+ `,
+ styles: `
+ .visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ }
+ `,