@clicktap/ui 2.2.0 → 2.3.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.
@@ -1 +1 @@
1
- "use client";import{jsx as e}from"react/jsx-runtime";import{forwardRef as m}from"react";import{Dialog as f}from"react-aria-components";import{motion as d}from"framer-motion";import{cn as u}from"../../utils/cn.js";const s=m(({style:o,animationVariants:i,...t},n)=>{const r=typeof o=="function"?o(t):o;return e(f,{...t,ref:n,style:r})});let a=null;function c(){return typeof window>"u"?null:(a||(a=d.create(s)),a)}const l=c();function D({className:o,children:i,animationVariants:t,...n}){const r={className:u("p-8 outline-0 max-w-max w-screen absolute top-2/4 left-2/4","-translate-x-1/2 -translate-y-1/2","shadow-[0_8px_24px_rgba(0,0,0,0.1)] rounded-lg bg-white border border-solid border-border-emphasis",o),...n};return l?e(l,{...r,variants:t||{hidden:{opacity:0},visible:{opacity:1}},children:i}):e(s,{...r,children:i})}export{D as Dialog,D as default};
1
+ "use client";import{jsx as e}from"react/jsx-runtime";import{forwardRef as s}from"react";import{Dialog as f}from"react-aria-components";import{motion as d}from"framer-motion";import{cn as u}from"../../utils/cn.js";const m=s(({style:o,animationVariants:a,...t},i)=>{const r=typeof o=="function"?o(t):o;return e(f,{...t,ref:i,style:r})});let n=null;function c(){return typeof window>"u"?null:(n||(n=d.create(m)),n)}const l=c();function y({className:o,children:a,animationVariants:t,...i}){const r={className:u("p-8 outline-0 max-w-max w-screen absolute top-2/4 left-2/4","-translate-x-1/2 -translate-y-1/2","max-h-[calc(100dvh-2rem)] overflow-y-auto","shadow-[0_8px_24px_rgba(0,0,0,0.1)] rounded-lg bg-white border border-solid border-border-emphasis",o),...i};return l?e(l,{...r,variants:t||{hidden:{opacity:0},visible:{opacity:1}},children:a}):e(m,{...r,children:a})}export{y as Dialog,y as default};
@@ -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: _name, onChange: controlledOnChange, value: _value, type, validationBehavior: _validationBehavior, className, classNames, inputProps, ...props }: PinInputProps): import("react/jsx-runtime").JSX.Element;
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 $,jsx as x}from"react/jsx-runtime";import{useState as L,useRef as R,useEffect as j,useCallback as V}from"react";import{Group as T,Label as _}from"react-aria-components";import{cn as f}from"../../utils/cn.js";function C({autoFocus:y=!1,description:D,errorMessage:b,isDisabled:I=!1,isInvalid:g=!1,isMasked:N=!1,isRequired:z=!0,label:k,length:i=6,name:P,onChange:d,value:S="",type:w="numeric",validationBehavior:W,className:B,classNames:p,inputProps:A,...J}){const[r,h]=L(()=>Array(i).fill("")),o=R([]);j(()=>{o.current=o.current.slice(0,i)},[i]),j(()=>{y&&o.current[0]?.focus()},[y]);const K=V(t=>{const e=t.target.value,u=Number(t.target.getAttribute("data-pin-input-index"));e!==""&&u<i-1&&o.current?.[u+1]?.focus();const l=r.map((s,c)=>c===u?e:s);h(l);const a=l.join("");d?.(a)},[r,i,d]),v=V(t=>{const e=Number(t.currentTarget.getAttribute("data-pin-input-index"));if(t.ctrlKey||t.metaKey)return;const u=["Backspace","Delete","ArrowLeft","ArrowRight","ArrowUp","ArrowDown","Control","Alt","Meta","Shift","Tab","Enter","Escape"],l={alpha:/^[a-z]$/i,alphanumeric:/^[a-z0-9]$/i,numeric:/^[0-9]$/i};if(!t.key.match(l[w])&&!u.includes(t.key)&&t.preventDefault(),r[e]===""&&e>0&&t.key==="Backspace"){const a=r.map((c,m)=>m===e-1?"":c);h(a),o.current?.[e-1]?.focus(),t.preventDefault();const s=a.join("");d?.(s)}if(r[e]===""&&e<r.length-1&&t.key==="Delete"){const a=r.map((c,m)=>m===e+1?"":c);h(a),o.current?.[e+1]?.focus(),t.preventDefault();const s=a.join("");d?.(s)}},[r,w,d]),E=V(t=>{const e=Number(t.currentTarget.getAttribute("data-pin-input-index"));t.preventDefault();const u=t.clipboardData?.getData("text");if(!u)return;const l=u.split("").filter(n=>{switch(w){case"alpha":return/^[a-zA-Z]$/.test(n);case"alphanumeric":return/^[a-z0-9]$/i.test(n);default:return/^[0-9]$/.test(n)}});if(l.length===0)return;const a=[...r];let s=e;for(let n=0;n<l.length&&e+n<i;n++)a[e+n]=l[n],s=e+n;h(a);const c=a.join("");d?.(c);const m=s+1<i?s+1:i-1;o.current[m]?.focus()},[r,w,i,d]);return $(T,{className:f("flex flex-wrap gap-2",B),"aria-label":k,...J,children:[k&&x(_,{className:f("flex text-fg-muted text-sm grow shrink-0 basis-full",p?.label),children:k}),r.map((t,e)=>x("div",{className:f("flex flex-col w-full flex-1 text-fg",p?.inputWrap),children:x("input",{type:N?"password":"text",autoComplete:"one-time-code","data-1p-ignore":"true","data-lpignore":"true","data-bwignore":"true","data-form-type":"other",...A,className:f("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",g&&"border-danger-soft bg-danger-surface text-danger",g&&"hover:border-danger-soft focus:border-danger-soft focus:outline-border-subtle",p?.input,A?.className),onChange:K,onKeyDown:v,onPaste:E,ref:u=>{o.current[e]=u},value:t,maxLength:1,"data-pin-input-index":e,disabled:I,required:z,"aria-label":`Pin Input Digit ${e+1}`})},`pin-input-${e}`)),(D||g&&b)&&$("div",{className:f("flex flex-row flex-wrap grow shrink-0 basis-full",p?.textWrap),children:[D&&x("p",{className:f("flex text-fg-muted text-sm grow shrink-0 basis-full",p?.description),children:D}),g&&b&&typeof b=="string"&&x("p",{className:f("flex text-danger text-sm grow shrink-0 basis-full",p?.error),children:b})]})]})}export{C as PinInput,C as default};
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 name, validationBehavior and isRequired */
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.
@@ -0,0 +1,32 @@
1
+ # BUGFIX: a `Dialog` taller than the viewport scrolls inside itself
2
+
3
+ **Date:** 2026-09-19
4
+ **Package:** `@clicktap/ui`
5
+ **Affects:** `src/components/Dialog/Dialog.tsx` — every `<Dialog>` rendered inside `<Modal>`.
6
+ **Classification:** BUGFIX
7
+ **Breaking:** No API change. **Visual:** a dialog whose content is taller than the viewport is now capped at the viewport height minus 2rem and scrolls its own content; shorter dialogs render exactly as before.
8
+ **Ticket:** CTAP-1791 (epic CTAP-1785)
9
+
10
+ ## Problem
11
+
12
+ `Dialog` is centered with `absolute top-2/4 left-2/4 -translate-x-1/2 -translate-y-1/2` and had
13
+ no height limit. A dialog taller than the viewport therefore grew past both edges: its top half
14
+ sat above the screen and its footer, the Cancel and Update buttons, below it, with no way to
15
+ scroll to them because the overlay itself does not scroll.
16
+
17
+ CTAP-1791 put the billing address fields into the saved-card edit form, so "Update payment
18
+ method" now carries cardholder name, expiry, postal code, ten address fields and the default
19
+ control. On a laptop viewport the buttons were off screen.
20
+
21
+ ## What changed
22
+
23
+ The dialog gets `max-h-[calc(100dvh-2rem)] overflow-y-auto`. The centering is untouched, so a
24
+ capped dialog still sits in the middle with 1rem clear of each edge and its content scrolls
25
+ inside the rounded, bordered box. `dvh` follows the mobile browser chrome as it shows and hides.
26
+
27
+ ## Consumer action
28
+
29
+ None. A consumer that already wrapped its dialog content in its own scroll container keeps
30
+ working; the outer cap only engages when the content is taller than the viewport. A consumer that
31
+ passes `className` with its own `max-h-*` or `overflow-*` still wins, since `className` merges
32
+ last.
@@ -1,4 +1,24 @@
1
1
  [
2
+ {
3
+ "path": "2026/09/ui-2026-09-19-dialog-scrolls-within-viewport.md",
4
+ "module": "libs/frontend-modules/ui",
5
+ "package": "@clicktap/ui",
6
+ "date": "2026-09-19",
7
+ "title": "BUGFIX: a `Dialog` taller than the viewport scrolls inside itself",
8
+ "classification": "BUGFIX",
9
+ "breaking": "No API change. **Visual:** a dialog whose content is taller than the viewport is now capped at the viewport height minus 2rem and scrolls its own content; shorter dialogs render exactly as before.",
10
+ "affects": "`src/components/Dialog/Dialog.tsx` — every `<Dialog>` rendered inside `<Modal>`."
11
+ },
12
+ {
13
+ "path": "2026/09/ui-2026-09-18-pin-input-autofill-and-controlled-value.md",
14
+ "module": "libs/frontend-modules/ui",
15
+ "package": "@clicktap/ui",
16
+ "date": "2026-09-18",
17
+ "title": "`PinInput`: Android autofill fills every box, mobile keyboards open numeric, and `value` is a real prop",
18
+ "classification": "BUGFIX (three) + IMPROVEMENT (`value`/`name` honored)",
19
+ "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.",
20
+ "affects": "`src/components/PinInput/PinInput.tsx`, `PinInput.types.ts`. Every consumer of `<PinInput>` (signup, forgot-password and email/password-change verification on the storefront)."
21
+ },
2
22
  {
3
23
  "path": "2026/09/ui-2026-09-16-modal-overlay-detached-motion-element.md",
4
24
  "module": "libs/frontend-modules/ui",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clicktap/ui",
3
- "version": "2.2.0",
3
+ "version": "2.3.1",
4
4
  "private": false,
5
5
  "author": "Clicktap",
6
6
  "description": "A library of React UI components and low-level hooks.",