@clicktap/ui 0.40.11 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/ResendCode/ResendCode.d.ts +9 -0
- package/components/ResendCode/ResendCode.js +1 -0
- package/components/ResendCode/ResendCode.types.d.ts +20 -0
- package/components/ResendCode/index.d.ts +2 -0
- package/components/ResendCode/index.js +1 -0
- package/docs/migrations/2026/04/2026-04-17-select-address-input-onchange-key-or-item.md +6 -2
- package/docs/migrations/2026/08/2026-08-04-resend-code-control.md +68 -0
- package/docs/migrations/README.md +1 -0
- package/hooks/useCooldown.d.ts +24 -0
- package/hooks/useCooldown.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ResendCodeProps } from './ResendCode.types';
|
|
2
|
+
/**
|
|
3
|
+
* "Didn't get a code? Resend" control for OTP flows. Presentation only — the caller owns the resend
|
|
4
|
+
* request and cooldown state (e.g. via a resend hook composing `useCooldown`). Renders one of three
|
|
5
|
+
* labels: the cooldown countdown, the in-flight label, or the idle action label; the button is
|
|
6
|
+
* disabled unless `canResend`.
|
|
7
|
+
*/
|
|
8
|
+
export declare function ResendCode({ cooldownSeconds, isResending, canResend, onResend, prompt, label, resendingLabel, className, buttonClassName, }: ResendCodeProps): import("react/jsx-runtime").JSX.Element;
|
|
9
|
+
export default ResendCode;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use client";import{jsxs as u,jsx as b}from"react/jsx-runtime";import{cn as n}from"../../utils/cn.js";function f({cooldownSeconds:e,isResending:t,canResend:d,onResend:s,prompt:i="Didn't get a code?",label:l="Resend code",resendingLabel:o="Sending…",className:a,buttonClassName:r}){const c=e>0?`Resend in ${e}s`:t?o:l;return u("p",{className:n("text-sm",a),children:[i," ",b("button",{type:"button",onClick:s,disabled:!d,className:n("underline disabled:no-underline disabled:opacity-50 disabled:cursor-not-allowed",r),children:c})]})}export{f as ResendCode,f as default};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type ResendCodeProps = {
|
|
2
|
+
/** Seconds remaining on the cooldown; when > 0 the control reads "Resend in Ns". */
|
|
3
|
+
cooldownSeconds: number;
|
|
4
|
+
/** True while a resend request is in flight (shows `resendingLabel`). */
|
|
5
|
+
isResending: boolean;
|
|
6
|
+
/** Whether the control is actionable (no active cooldown and not in flight). */
|
|
7
|
+
canResend: boolean;
|
|
8
|
+
/** Fired when the user activates the control. */
|
|
9
|
+
onResend: () => void;
|
|
10
|
+
/** Leading prompt text. Defaults to "Didn't get a code?". */
|
|
11
|
+
prompt?: string;
|
|
12
|
+
/** Idle action label. Defaults to "Resend code". */
|
|
13
|
+
label?: string;
|
|
14
|
+
/** Label shown while a resend is in flight. Defaults to "Sending…". */
|
|
15
|
+
resendingLabel?: string;
|
|
16
|
+
/** className for the wrapping paragraph. */
|
|
17
|
+
className?: string;
|
|
18
|
+
/** className for the action button. */
|
|
19
|
+
buttonClassName?: string;
|
|
20
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{ResendCode as p}from"./ResendCode.js";import"react/jsx-runtime";import"../../utils/cn.js";export{p as ResendCode};
|
|
@@ -76,15 +76,19 @@ The cleanest fix is a type guard at the callback boundary:
|
|
|
76
76
|
> ```ts
|
|
77
77
|
> import type { Key } from 'react-aria-components';
|
|
78
78
|
>
|
|
79
|
-
> export function narrowKey<T extends { id
|
|
79
|
+
> export function narrowKey<T extends { id?: Key }>(
|
|
80
80
|
> value: Key | T | null,
|
|
81
81
|
> ): Key | null {
|
|
82
82
|
> if (value === null) return null;
|
|
83
83
|
> if (typeof value === 'string' || typeof value === 'number') return value;
|
|
84
|
-
> return value.id;
|
|
84
|
+
> return value.id ?? null;
|
|
85
85
|
> }
|
|
86
86
|
> ```
|
|
87
87
|
>
|
|
88
|
+
> The constraint is `{ id?: Key }` (optional), not `{ id: Key }`: real call sites often can't statically prove the item carries a **required** `id` — the inline versions cast `(value as { id?: string }).id` — so a required-`id` constraint fails to typecheck exactly where the helper is meant to be used. Returning `value.id ?? null` keeps the return type `Key | null`.
|
|
89
|
+
>
|
|
90
|
+
> **Behaviour note:** this helper returns `null` where an inline `value.id` could return `undefined`. Harmless for callers guarding with `?? ''` or `== null`, but it is a real behaviour change — name it if any call site distinguishes the two.
|
|
91
|
+
>
|
|
88
92
|
> Then each call site becomes `onSelectionChange={(v) => { const key = narrowKey(v); if (key !== null) selectItemByKey(key); }}`. We may upstream this helper to `@clicktap/ui/utils` in a future release; for now, ship it locally if your codebase has enough call sites to justify the abstraction.
|
|
89
93
|
|
|
90
94
|
### 2. If you need the full item, accept it directly
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# `ResendCode` control + `useCooldown` hook
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-08-04
|
|
4
|
+
**Affects:** `@clicktap/ui` — new `components/ResendCode` and `hooks/useCooldown` exports.
|
|
5
|
+
**Classification:** IMPROVEMENT (new capability)
|
|
6
|
+
**Breaking:** No — additive exports.
|
|
7
|
+
**Consumers:** the OTP resend timer — [wire it everywhere](../../../../../../apps/frontend/docs/migrations/2026/08/2026-08-04-otp-resend-timer.md) ·
|
|
8
|
+
GraphQL contract — [resend mutations](../../../../../../libs/mezzio-modules/user/docs/migrations/2026/08/2026-08-04-otp-resend-mutations.md)
|
|
9
|
+
|
|
10
|
+
## Summary
|
|
11
|
+
|
|
12
|
+
Two additive, headless-friendly exports for building "Resend code" controls (CTAP-1114), so every OTP
|
|
13
|
+
flow renders the same states from one place instead of hand-rolling the markup per form.
|
|
14
|
+
|
|
15
|
+
### `@clicktap/ui/hooks/useCooldown`
|
|
16
|
+
|
|
17
|
+
A one-second countdown. `start(n)` sets the remaining seconds and it ticks to zero.
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
const { seconds, isActive, start, reset, isActiveNow } = useCooldown();
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
| Field | |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `seconds` | remaining seconds (0 when inactive) — drives rendering |
|
|
26
|
+
| `isActive` | `seconds > 0` |
|
|
27
|
+
| `start(seconds)` | begin/restart (rounds up, floors at 0) |
|
|
28
|
+
| `reset()` | clear immediately |
|
|
29
|
+
| `isActiveNow()` | **stale-free** active check for use inside event handlers / re-entry guards |
|
|
30
|
+
|
|
31
|
+
**StrictMode-safe:** the tick lives in an effect (never in a state updater), and the count is mirrored
|
|
32
|
+
into a ref so the effect reads the latest value and `isActiveNow()` never goes stale. Do **not** put a
|
|
33
|
+
side effect inside a `setState` updater to guard re-entry — StrictMode double-invokes updaters and it
|
|
34
|
+
fires twice.
|
|
35
|
+
|
|
36
|
+
### `@clicktap/ui/components/ResendCode`
|
|
37
|
+
|
|
38
|
+
Presentational only — the caller owns the request + cooldown state. Renders one of three labels
|
|
39
|
+
(countdown / in-flight / idle) and disables the button unless `canResend`.
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
<ResendCode
|
|
43
|
+
cooldownSeconds={seconds}
|
|
44
|
+
isResending={isResending}
|
|
45
|
+
canResend={canResend}
|
|
46
|
+
onResend={resend}
|
|
47
|
+
prompt="Didn't get a code?" // default
|
|
48
|
+
label="Resend code" // default idle label
|
|
49
|
+
resendingLabel="Sending…" // default
|
|
50
|
+
className="…" // wrapper <p>
|
|
51
|
+
buttonClassName="…" // action <button>
|
|
52
|
+
/>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Renders `Didn't get a code? {Resend code | Resend in Ns | Sending…}`. Style per context via
|
|
56
|
+
`className` / `buttonClassName` (tailwind-merge, so your `text-xs` overrides the default `text-sm`).
|
|
57
|
+
|
|
58
|
+
## Action required (consumer)
|
|
59
|
+
|
|
60
|
+
1. **Bump `@clicktap/ui`** to the release that ships this migration.
|
|
61
|
+
2. Use `<ResendCode>` for OTP resend controls; drive it from `useCooldown` (or a domain hook that
|
|
62
|
+
composes it — see the frontend guide's `useResendCode`).
|
|
63
|
+
|
|
64
|
+
## Notes
|
|
65
|
+
|
|
66
|
+
- `ResendCode` is a plain `<button type="button">` — safe inside a `react-aria` `Form` (won't submit).
|
|
67
|
+
- `useCooldown` is domain-agnostic; the OTP rate-limit contract (`otp_rate_limited`,
|
|
68
|
+
`retryAfterSeconds`) lives in the consuming app, not here.
|
|
@@ -27,5 +27,6 @@ For migrations affecting the frontend app, see [frontend migrations](../../../..
|
|
|
27
27
|
| 2026-05-29 | [`PasswordCheck` — the `requirements` variant (adoption guide for forks)](./2026/05/2026-05-29-password-check-requirements-variant.md) | No | PasswordCheck hide-on-empty |
|
|
28
28
|
| 2026-06-17 | [Select standardization — one `<Select searchable>`, `DropdownSelect` removed](./2026/06/2026-06-17-select-standardization.md) ↔ | Yes | *None* |
|
|
29
29
|
| 2026-06-18 | [Select (searchable): fix "setValue is not a function" crash on browser autofill](./2026/06/2026-06-18-select-hiddenselect-setvalue-crash.md) | No | Select standardization |
|
|
30
|
+
| 2026-08-04 | [`ResendCode` control + `useCooldown` hook](./2026/08/2026-08-04-resend-code-control.md) ↔ | No | OTP resend timer (CTAP-1114) |
|
|
30
31
|
|
|
31
32
|
↔ = cross-app migration (has counterpart in frontend app)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface UseCooldownResult {
|
|
2
|
+
/** Seconds remaining; 0 when inactive. Drives rendering (e.g. "Resend in Ns"). */
|
|
3
|
+
seconds: number;
|
|
4
|
+
/** True while a cooldown is counting down. */
|
|
5
|
+
isActive: boolean;
|
|
6
|
+
/** Begin (or restart) the cooldown at `seconds` (rounded up, floored at 0). */
|
|
7
|
+
start: (seconds: number) => void;
|
|
8
|
+
/** Clear the cooldown immediately. */
|
|
9
|
+
reset: () => void;
|
|
10
|
+
/**
|
|
11
|
+
* Stale-free active check for use inside event handlers / re-entry guards, where the `seconds`
|
|
12
|
+
* state captured by a stable callback would be out of date.
|
|
13
|
+
*/
|
|
14
|
+
isActiveNow: () => boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A headless one-second countdown. `start(n)` sets the remaining seconds and it ticks down to zero.
|
|
18
|
+
*
|
|
19
|
+
* StrictMode-safe: the tick lives in an effect (not a state updater), and the countdown is mirrored
|
|
20
|
+
* into a ref so the effect reads the latest value without re-subscribing each second and so callers
|
|
21
|
+
* get a stale-free view via `isActiveNow()`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function useCooldown(): UseCooldownResult;
|
|
24
|
+
export default useCooldown;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use client";import{useState as i,useRef as f,useCallback as r,useEffect as l}from"react";function d(){const[s,n]=i(0),t=f(0),o=r(c=>{const e=Math.max(0,Math.ceil(c));t.current=e,n(e)},[]),u=r(()=>{t.current=0,n(0)},[]),a=r(()=>t.current>0,[]);return l(()=>{if(s<=0)return;const c=setTimeout(()=>{const e=Math.max(0,t.current-1);t.current=e,n(e)},1e3);return()=>clearTimeout(c)},[s]),{seconds:s,isActive:s>0,start:o,reset:u,isActiveNow:a}}export{d as default,d as useCooldown};
|