@clicktap/ui 0.40.12 → 0.41.1
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/Input/Input.js +1 -1
- 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/08/2026-08-04-password-reveal-press-and-hold.md +48 -0
- package/docs/migrations/2026/08/2026-08-04-resend-code-control.md +68 -0
- package/docs/migrations/README.md +2 -0
- package/hooks/useCooldown.d.ts +24 -0
- package/hooks/useCooldown.js +1 -0
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
"use client";import{jsxs as
|
|
1
|
+
"use client";import{jsxs as l,jsx as e}from"react/jsx-runtime";import{Label as x,TextField as k,Input as g,Text as L,FieldError as C}from"react-aria-components";import{forwardRef as I,useState as N}from"react";import{cn as t}from"../../utils/cn.js";import{Skeleton as P}from"../Skeleton/Skeleton.js";import M from"../../hooks/useIsClient.js";function S({className:r}){return l("svg",{xmlns:"http://www.w3.org/2000/svg",fill:"none",viewBox:"0 0 24 24",strokeWidth:1.5,stroke:"currentColor",className:r,children:[e("path",{strokeLinecap:"round",strokeLinejoin:"round",d:"M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.64 0 8.577 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.64 0-8.577-3.007-9.963-7.178Z"}),e("path",{strokeLinecap:"round",strokeLinejoin:"round",d:"M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"})]})}function j({className:r}){return e("svg",{xmlns:"http://www.w3.org/2000/svg",fill:"none",viewBox:"0 0 24 24",strokeWidth:1.5,stroke:"currentColor",className:r,children:e("path",{strokeLinecap:"round",strokeLinejoin:"round",d:"M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88"})})}function y({className:r}){return e(P,{className:t("w-full h-10 rounded-md z-20 relative",r)})}const W=I(({inputProps:r,label:s,description:d,errorMessage:p,placeholder:h,className:u,classNames:o,disableSkeleton:m,type:c,...w},b)=>{const v=M(),a=c==="password",[f,n]=N(!1);return!v&&!m?l("div",{className:t("flex flex-col w-full text-slate-900",u),children:[s?e(x,{className:t("flex text-slate-500 text-xs",o?.label),children:s}):null,e(y,{className:o?.skeleton})]}):l(k,{className:t("flex flex-col w-full text-slate-900",u),type:a?f?"text":"password":c,...w,children:[e(x,{className:t("flex text-slate-500 text-xs",o?.label),children:s}),l("div",{className:"relative",children:[e(g,{ref:b,placeholder:h,className:t("border-solid border border-slate-300 rounded-md","text-sm text-slate-900 placeholder-slate-400","h-10 px-2 py-0 m-0 w-full","bg-white","transition-all duration-200 ease-in-out","hover:border-slate-400","focus:outline-2 focus:outline focus:outline-slate-200 focus:border-slate-400","disabled:border-slate-200 disabled:bg-slate-100","invalid:border-red-500 invalid:bg-red-100 invalid:text-red-600","invalid:hover:border-red-600 invalid:focus:border-red-600 invalid:focus:outline-red-200",a&&"pr-10",o?.input),...r}),a&&e("button",{type:"button",className:"absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600 bg-transparent border-none cursor-pointer select-none",onPointerDown:i=>{i.preventDefault(),n(!0)},onPointerUp:()=>n(!1),onPointerLeave:()=>n(!1),onPointerCancel:()=>n(!1),onContextMenu:i=>i.preventDefault(),"aria-label":"Show password while pressed",tabIndex:-1,children:f?e(j,{className:"w-5 h-5"}):e(S,{className:"w-5 h-5"})})]}),d&&e(L,{className:t("flex text-slate-500 text-sm",o?.description),slot:"description",children:d}),e(C,{className:t("flex text-red-500 text-sm",o?.error),children:p})]})});export{W as Input,W as default};
|
|
@@ -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};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Password reveal is press-and-hold, not a toggle
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-08-04
|
|
4
|
+
**Affects:** `@clicktap/ui` — `Input` with `type="password"`.
|
|
5
|
+
**Classification:** IMPROVEMENT (security / UX)
|
|
6
|
+
**Breaking:** Behavioural only — the reveal changed from a click-toggle to press-and-hold. No API/prop
|
|
7
|
+
change; adoption is automatic.
|
|
8
|
+
**Ticket:** CTAP-1128
|
|
9
|
+
|
|
10
|
+
## Summary
|
|
11
|
+
|
|
12
|
+
The password-field reveal (the eye button in `Input`) now **shows the value only while the control is
|
|
13
|
+
actively held**, and hides it the instant the press ends. Previously it was a click **toggle**, which
|
|
14
|
+
could leave a password on screen after a stray click and widened the shoulder-surfing window. Press-and-
|
|
15
|
+
hold ties visibility to the moment of intent.
|
|
16
|
+
|
|
17
|
+
## What changed
|
|
18
|
+
|
|
19
|
+
The reveal button swapped its `onClick` toggle for pointer press-and-hold:
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
onPointerDown={(e) => { e.preventDefault(); setShowPassword(true); }}
|
|
23
|
+
onPointerUp={() => setShowPassword(false)}
|
|
24
|
+
onPointerLeave={() => setShowPassword(false)}
|
|
25
|
+
onPointerCancel={() => setShowPassword(false)}
|
|
26
|
+
onContextMenu={(e) => e.preventDefault()}
|
|
27
|
+
aria-label="Show password while pressed"
|
|
28
|
+
tabIndex={-1}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- **Pointer events** (not mouse-only) so mouse and touch both work from one code path.
|
|
32
|
+
- `preventDefault()` on press keeps focus on the input while peeking (you can keep typing).
|
|
33
|
+
- `onPointerLeave` / `onPointerCancel` cover dragging off the button or an interrupted touch, so the
|
|
34
|
+
value can't get stuck visible.
|
|
35
|
+
- **Unchanged:** the reveal is still pointer-only (`tabIndex={-1}`, not in the tab order) — this migration
|
|
36
|
+
does not add keyboard reveal.
|
|
37
|
+
|
|
38
|
+
## Action required (consumer)
|
|
39
|
+
|
|
40
|
+
- **Using `@clicktap/ui` `Input`:** nothing — the behaviour ships with the component.
|
|
41
|
+
- **Rolled your own password reveal:** mirror the pattern above (swap the `onClick` toggle for the
|
|
42
|
+
pointer handlers) so behaviour is consistent across your app. The reference frontend also updated its
|
|
43
|
+
one bespoke reveal (`UpdateEmailForm`) to match.
|
|
44
|
+
|
|
45
|
+
## Verify
|
|
46
|
+
|
|
47
|
+
Press and hold the eye control → the characters show; release (or drag the pointer off) → they hide
|
|
48
|
+
again. A single click no longer leaves the password visible.
|
|
@@ -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,7 @@ 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) |
|
|
31
|
+
| 2026-08-04 | [Password reveal is press-and-hold, not a toggle](./2026/08/2026-08-04-password-reveal-press-and-hold.md) | Behavioural | *None* (CTAP-1128) |
|
|
30
32
|
|
|
31
33
|
↔ = 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};
|