@clicktap/ui 2.2.0 → 2.3.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/PinInput/PinInput.d.ts +1 -1
- package/components/PinInput/PinInput.js +1 -1
- package/components/PinInput/PinInput.types.d.ts +6 -1
- package/docs/migrations/2026/09/ui-2026-09-18-pin-input-autofill-and-controlled-value.md +57 -0
- package/docs/migrations/index.json +10 -0
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { PinInputProps } from './PinInput.types';
|
|
2
2
|
/** based on https://github.com/chakra-ui/chakra-ui/blob/main/packages/components/src/pin-input/use-pin-input.ts */
|
|
3
|
-
export declare function PinInput({ autoFocus, description, errorMessage, isDisabled, isInvalid, isMasked, isRequired, label, length, name
|
|
3
|
+
export declare function PinInput({ autoFocus, description, errorMessage, isDisabled, isInvalid, isMasked, isRequired, label, length, name, onChange: controlledOnChange, value, type, validationBehavior: _validationBehavior, className, classNames, inputProps, ...props }: PinInputProps): import("react/jsx-runtime").JSX.Element;
|
|
4
4
|
export default PinInput;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"use client";import{jsxs as
|
|
1
|
+
"use client";import{jsxs as L,jsx as m}from"react/jsx-runtime";import{useState as G,useRef as H,useCallback as b,useEffect as $}from"react";import{Group as U,Label as J}from"react-aria-components";import{cn as u}from"../../utils/cn.js";const j={alpha:/^[a-z]$/i,alphanumeric:/^[a-z0-9]$/i,numeric:/^[0-9]$/};function E(g,x){const c=g.split("");return Array.from({length:x},(k,f)=>c[f]??"")}function Z({autoFocus:g=!1,description:x,errorMessage:c,isDisabled:k=!1,isInvalid:f=!1,isMasked:I=!1,isRequired:K=!0,label:D,length:o=6,name:y,onChange:C,value:A,type:d="numeric",validationBehavior:M,className:R,classNames:p,inputProps:N,...W}){const v=A!==void 0,[T,_]=G(()=>E(A??"",o)),r=v?E(A,o):T,s=H([]),l=b(e=>{_(e),C?.(e.join(""))},[C]),h=b(e=>j[d].test(e),[d]),w=b((e,t)=>{const n=[...r];let a=e;for(let i=0;i<t.length&&e+i<o;i++)n[e+i]=t[i],a=e+i;l(n),s.current[Math.min(a+1,o-1)]?.focus()},[r,o,l]);$(()=>{s.current=s.current.slice(0,o)},[o]),$(()=>{g&&s.current[0]?.focus()},[g]);const z=b(e=>{const t=e.target.value,n=Number(e.target.getAttribute("data-pin-input-index"));if(t===""){l(r.map((V,q)=>q===n?"":V));return}const a=r[n],B=(a!==""&&t.startsWith(a)?t.slice(a.length):t).split("").filter(h);B.length!==0&&w(n,B)},[r,l,h,w]),P=b(e=>{const t=Number(e.currentTarget.getAttribute("data-pin-input-index"));if(e.ctrlKey||e.metaKey)return;const n=["Backspace","Delete","ArrowLeft","ArrowRight","ArrowUp","ArrowDown","Control","Alt","Meta","Shift","Tab","Enter","Escape"];!j[d].test(e.key)&&!n.includes(e.key)&&e.preventDefault(),r[t]===""&&t>0&&e.key==="Backspace"&&(l(r.map((a,i)=>i===t-1?"":a)),s.current?.[t-1]?.focus(),e.preventDefault()),r[t]===""&&t<r.length-1&&e.key==="Delete"&&(l(r.map((a,i)=>i===t+1?"":a)),s.current?.[t+1]?.focus(),e.preventDefault())},[r,d,l]),S=b(e=>{const t=Number(e.currentTarget.getAttribute("data-pin-input-index"));e.preventDefault();const n=e.clipboardData?.getData("text");if(!n)return;const a=n.split("").filter(h);a.length!==0&&w(t,a)},[h,w]);return L(U,{className:u("flex flex-wrap gap-2",R),"aria-label":D,...W,children:[D&&m(J,{className:u("flex text-fg-muted text-sm grow shrink-0 basis-full",p?.label),children:D}),r.map((e,t)=>m("div",{className:u("flex flex-col w-full flex-1 text-fg",p?.inputWrap),children:m("input",{type:I?"password":"text",autoComplete:t===0?"one-time-code":"off",inputMode:d==="numeric"?"numeric":"text",pattern:d==="numeric"?"[0-9]*":void 0,"data-1p-ignore":"true","data-lpignore":"true","data-bwignore":"true","data-form-type":"other",...N,className:u("border-solid border rounded-md","text-sm text-fg placeholder-fg-subtle text-center","h-10 px-2 py-0 m-0 w-full","bg-white","[&::-ms-reveal]:hidden [&::-ms-clear]:hidden","transition-all duration-200 ease-in-out","hover:border-border-emphasis","focus:outline-2 focus:outline focus:outline-border-subtle focus:border-border-emphasis","disabled:border-border-subtle disabled:bg-surface-subtle",f&&"border-danger-soft bg-danger-surface text-danger",f&&"hover:border-danger-soft focus:border-danger-soft focus:outline-border-subtle",p?.input,N?.className),onChange:z,onKeyDown:P,onPaste:S,ref:n=>{s.current[t]=n},value:e,maxLength:void 0,"data-pin-input-index":t,disabled:k,required:K,"aria-label":`Pin Input Digit ${t+1}`})},`pin-input-${t}`)),y&&m("input",{type:"hidden",name:y,value:r.join("")}),(x||f&&c)&&L("div",{className:u("flex flex-row flex-wrap grow shrink-0 basis-full",p?.textWrap),children:[x&&m("p",{className:u("flex text-fg-muted text-sm grow shrink-0 basis-full",p?.description),children:x}),f&&c&&typeof c=="string"&&m("p",{className:u("flex text-danger text-sm grow shrink-0 basis-full",p?.error),children:c})]})]})}export{Z as PinInput,Z as default};
|
|
@@ -6,7 +6,7 @@ import { SlotsToClasses } from '../../types/SlotsToClasses';
|
|
|
6
6
|
* `inputProps` — they are the component's wiring, and overriding them breaks it.
|
|
7
7
|
*/
|
|
8
8
|
type OwnedInputProps = 'value' | 'onChange' | 'onKeyDown' | 'onPaste' | 'ref' | 'maxLength' | 'disabled' | 'required';
|
|
9
|
-
/** @todo extend certain textfield props like
|
|
9
|
+
/** @todo extend certain textfield props like validationBehavior and isRequired */
|
|
10
10
|
export interface PinInputProps extends GroupProps {
|
|
11
11
|
/**
|
|
12
12
|
* Extra props for each digit `<input>` — the escape hatch for anything the component does not
|
|
@@ -22,8 +22,13 @@ export interface PinInputProps extends GroupProps {
|
|
|
22
22
|
errorMessage?: string | ((validation: ValidationResult) => string);
|
|
23
23
|
label?: string;
|
|
24
24
|
length?: number;
|
|
25
|
+
/** Rendered as a hidden input carrying the joined code, so a native form submit sees it. */
|
|
25
26
|
name?: string;
|
|
26
27
|
onChange?: (...event: any[]) => void;
|
|
28
|
+
/**
|
|
29
|
+
* The joined code. When given, the component is controlled: the boxes are derived from it, so a
|
|
30
|
+
* form's reset()/setValue('') clears them (CTAP-1749). Omit it for an uncontrolled input.
|
|
31
|
+
*/
|
|
27
32
|
value?: string;
|
|
28
33
|
isMasked?: boolean;
|
|
29
34
|
isRequired?: boolean;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# `PinInput`: Android autofill fills every box, mobile keyboards open numeric, and `value` is a real prop
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-09-18
|
|
4
|
+
**Package:** `@clicktap/ui`
|
|
5
|
+
**Affects:** `src/components/PinInput/PinInput.tsx`, `PinInput.types.ts`. Every consumer of `<PinInput>` (signup, forgot-password and email/password-change verification on the storefront).
|
|
6
|
+
**Classification:** BUGFIX (three) + IMPROVEMENT (`value`/`name` honored)
|
|
7
|
+
**Breaking:** No API change. **Behavioral:** passing `value` now controls the boxes (it was silently ignored); `maxLength` is no longer rendered; `autoComplete="one-time-code"` is on the first box only.
|
|
8
|
+
**Ticket:** CTAP-1749 (from NOCOE-2944)
|
|
9
|
+
|
|
10
|
+
## What was wrong
|
|
11
|
+
|
|
12
|
+
Four things, all in this component (a consumer wrapper such as noco's `libs/noco-ui` only restyles it):
|
|
13
|
+
|
|
14
|
+
1. **Android autofill and Gboard's clipboard chip lost five of six digits.** The paste handler split
|
|
15
|
+
digits across the boxes correctly, but autofill and the keyboard chip insert text as an *input*
|
|
16
|
+
event, not a paste event, so they went through the plain change handler, where `maxLength={1}`
|
|
17
|
+
kept only the first character. Desktop Ctrl+V worked, which is why it looked fixed.
|
|
18
|
+
2. **No `inputMode`.** A numeric code opened the full keyboard on phones.
|
|
19
|
+
3. **`autoComplete="one-time-code"` on all six boxes.** iOS offers the SMS code to the field that
|
|
20
|
+
advertises it; six advertising boxes offered the whole code to whichever one had focus.
|
|
21
|
+
4. **`value` and `name` were accepted and discarded.** The component kept its own state, so
|
|
22
|
+
react-hook-form's `reset()` / `setValue('')` after a wrong code left six stale digits in place.
|
|
23
|
+
|
|
24
|
+
## What changed
|
|
25
|
+
|
|
26
|
+
- The change handler now distributes a multi-character value from the box that received it, the
|
|
27
|
+
way the paste handler always did: filtered to the code's alphabet, one character per box, extras
|
|
28
|
+
dropped, focus on the box after the last one written, **one** `onChange` with the joined code.
|
|
29
|
+
`maxLength` is gone (it was the truncation) and is pinned to `undefined` after `inputProps` so a
|
|
30
|
+
consumer cannot put it back.
|
|
31
|
+
- `inputMode="numeric"` and `pattern="[0-9]*"` when `type` is numeric; `inputMode="text"` otherwise.
|
|
32
|
+
- `autoComplete="one-time-code"` on the first box, `off` on the rest. The password-manager opt-outs
|
|
33
|
+
stay on every box.
|
|
34
|
+
- `value`, when given, controls the boxes: they are derived from it, so an empty value clears them.
|
|
35
|
+
Omit it for an uncontrolled input, as before. `name` renders a hidden input carrying the joined
|
|
36
|
+
code, so a native form submit sees it.
|
|
37
|
+
|
|
38
|
+
## ⚠️ Consumer impact
|
|
39
|
+
|
|
40
|
+
- **If you pass `value`, it now means something.** Every storefront usage passes
|
|
41
|
+
`value={value ?? ''}` from a `Controller`, which is exactly the controlled shape: typing calls
|
|
42
|
+
`onChange`, the form stores the code, the boxes re-derive from it. A consumer that passes a stale
|
|
43
|
+
or constant `value` (say, `value=""` without wiring `onChange` into state) will find the boxes
|
|
44
|
+
never fill. Wire `onChange` to whatever produces `value`, or drop `value` for uncontrolled use.
|
|
45
|
+
- A consumer that overrode `autoComplete` through `inputProps` still wins: `inputProps` is applied
|
|
46
|
+
after the defaults.
|
|
47
|
+
- Nothing to change for the four storefront forms; they get all four fixes by taking the release.
|
|
48
|
+
|
|
49
|
+
## Verification
|
|
50
|
+
|
|
51
|
+
`PinInput.test.tsx` covers each: a multi-character change event on box 1 fills all six and reports
|
|
52
|
+
the code once; distribution from a middle box drops the overflow; an autofilled sentence keeps only
|
|
53
|
+
the digits; `inputmode`/`pattern` per type; `value=""` clears; the hidden `name` input carries the
|
|
54
|
+
joined code; a single typed digit still advances. All were run against the previous component and
|
|
55
|
+
failed before the change. On a device: Android Chrome with Gboard, tap the clipboard chip on the
|
|
56
|
+
code, all six boxes fill; iOS Messages offers the code on the first box; a wrong code followed by
|
|
57
|
+
the form's reset empties the boxes.
|
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"path": "2026/09/ui-2026-09-18-pin-input-autofill-and-controlled-value.md",
|
|
4
|
+
"module": "libs/frontend-modules/ui",
|
|
5
|
+
"package": "@clicktap/ui",
|
|
6
|
+
"date": "2026-09-18",
|
|
7
|
+
"title": "`PinInput`: Android autofill fills every box, mobile keyboards open numeric, and `value` is a real prop",
|
|
8
|
+
"classification": "BUGFIX (three) + IMPROVEMENT (`value`/`name` honored)",
|
|
9
|
+
"breaking": "No API change. **Behavioral:** passing `value` now controls the boxes (it was silently ignored); `maxLength` is no longer rendered; `autoComplete=\"one-time-code\"` is on the first box only.",
|
|
10
|
+
"affects": "`src/components/PinInput/PinInput.tsx`, `PinInput.types.ts`. Every consumer of `<PinInput>` (signup, forgot-password and email/password-change verification on the storefront)."
|
|
11
|
+
},
|
|
2
12
|
{
|
|
3
13
|
"path": "2026/09/ui-2026-09-16-modal-overlay-detached-motion-element.md",
|
|
4
14
|
"module": "libs/frontend-modules/ui",
|