@clicktap/ui 2.1.3 → 2.2.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.
@@ -19,5 +19,5 @@ import { ModalOverlayProps } from './ModalOverlay.types';
19
19
  * Exported for unit tests only.
20
20
  */
21
21
  export declare function useOverlayAnimationState(overlayState: OverlayTriggerState | null | undefined): [DriverAnimationState, Dispatch<SetStateAction<DriverAnimationState>>];
22
- export declare function ModalOverlay(props: ModalOverlayProps): import("react/jsx-runtime").JSX.Element;
22
+ export declare function ModalOverlay(props: ModalOverlayProps): import("react/jsx-runtime").JSX.Element | null;
23
23
  export default ModalOverlay;
@@ -1 +1 @@
1
- "use client";import{jsx as a}from"react/jsx-runtime";import{forwardRef as N,useContext as M,useState as k,useEffect as A,useId as R,useCallback as O}from"react";import{ModalOverlay as E,OverlayTriggerStateContext as w}from"react-aria-components";import{motion as T,AnimatePresence as F}from"framer-motion";import{cn as j}from"../../utils/cn.js";const q="body > [inert]:not(#__next):not([data-overlay-container])";function z(e){return Object.keys(e).some(t=>t.startsWith("__react"))}function p(){document.querySelectorAll(q).forEach(e=>e.removeAttribute("inert"))}function L(e){return!!(e.closest("[data-overlay-container]")||e.closest("#__next"))}const W=N(({style:e,children:t,...i},n)=>{const r=typeof e=="function"?e(i):e;return a(E,{...i,ref:n,style:r,children:t})});let m=null;function $(){return typeof window>"u"?null:(m||(m=T.create(W)),m)}const h=$();function g({animate:e,animation:t,setAnimation:i,className:n,animationVariants:r,isDismissable:u=!0,children:v,...x}){const C=R();A(()=>{p();const o=new MutationObserver(l=>{for(const d of l)if(d.type==="attributes"&&d.attributeName==="inert"){const f=d.target;f.parentElement===document.body?p():z(f)||f.removeAttribute("inert")}});return o.observe(document.body,{attributes:!0,attributeFilter:["inert"],subtree:!0}),()=>o.disconnect()},[]);const{key:S,shouldCloseOnInteractOutside:c,onOpenChange:s,..._}=x,P=O(o=>c?c(o):L(o),[c]),y=M(w),I=O(o=>{!u||o.target!==o.currentTarget||(s?s(!1):y?.close())},[u,s,y]),b={isDismissable:u,shouldCloseOnInteractOutside:P,onOpenChange:s,onPointerDown:I,className:j("bg-black/30 backdrop-blur-sm","fixed top-0 left-0","z-[1000]","w-screen h-[var(--visual-viewport-height)]",n),..._};return h?a(h,{isExiting:t==="hidden",onAnimationComplete:o=>{i(l=>o==="hidden"&&l==="hidden"?"unmounted":l)},variants:r||{hidden:{opacity:0,transition:{delay:.08}},visible:{opacity:1}},initial:"hidden",animate:e,exit:"hidden",...b,children:v},S||C):a(E,{...b,children:v})}function B(e){const t=e?.isOpen??!1,[i,n]=k(t?"visible":"unmounted");return A(()=>{n(t?"visible":r=>r==="visible"?"hidden":r)},[t]),[i,n]}function Q(e){const{isOpen:t}=e,i=M(w),[n,r]=B(i);return t!==void 0?a(F,{children:t&&a(g,{...e,animate:"visible",animation:n,setAnimation:r})}):a(g,{...e,animate:n,animation:n,setAnimation:r})}export{Q as ModalOverlay,Q as default,B as useOverlayAnimationState};
1
+ "use client";import{jsx as a}from"react/jsx-runtime";import{forwardRef as N,useContext as M,useState as k,useEffect as A,useId as R,useCallback as O}from"react";import{ModalOverlay as E,OverlayTriggerStateContext as w}from"react-aria-components";import{motion as T,AnimatePresence as F}from"framer-motion";import{cn as j}from"../../utils/cn.js";const q="body > [inert]:not(#__next):not([data-overlay-container])";function z(e){return Object.keys(e).some(t=>t.startsWith("__react"))}function p(){document.querySelectorAll(q).forEach(e=>e.removeAttribute("inert"))}function L(e){return!!(e.closest("[data-overlay-container]")||e.closest("#__next"))}const W=N(({style:e,children:t,...i},n)=>{const r=typeof e=="function"?e(i):e;return a(E,{...i,ref:n,style:r,children:t})});let m=null;function $(){return typeof window>"u"?null:(m||(m=T.create(W)),m)}const h=$();function g({animate:e,animation:t,setAnimation:i,className:n,animationVariants:r,isDismissable:u=!0,children:v,...x}){const C=R();A(()=>{p();const o=new MutationObserver(l=>{for(const d of l)if(d.type==="attributes"&&d.attributeName==="inert"){const f=d.target;f.parentElement===document.body?p():z(f)||f.removeAttribute("inert")}});return o.observe(document.body,{attributes:!0,attributeFilter:["inert"],subtree:!0}),()=>o.disconnect()},[]);const{key:S,shouldCloseOnInteractOutside:c,onOpenChange:s,..._}=x,P=O(o=>c?c(o):L(o),[c]),y=M(w),I=O(o=>{!u||o.target!==o.currentTarget||(s?s(!1):y?.close())},[u,s,y]),b={isDismissable:u,shouldCloseOnInteractOutside:P,onOpenChange:s,onPointerDown:I,className:j("bg-black/30 backdrop-blur-sm","fixed top-0 left-0","z-[1000]","w-screen h-[var(--visual-viewport-height)]",n),..._};return h?a(h,{isExiting:t==="hidden",onAnimationComplete:o=>{i(l=>o==="hidden"&&l==="hidden"?"unmounted":l)},variants:r||{hidden:{opacity:0,transition:{delay:.08}},visible:{opacity:1}},initial:"hidden",animate:e,exit:"hidden",...b,children:v},S||C):a(E,{...b,children:v})}function B(e){const t=e?.isOpen??!1,[i,n]=k(t?"visible":"unmounted");return A(()=>{n(t?"visible":r=>r==="visible"?"hidden":r)},[t]),[i,n]}function Q(e){const{isOpen:t}=e,i=M(w),[n,r]=B(i);return t!==void 0?a(F,{children:t&&a(g,{...e,animate:"visible",animation:n,setAnimation:r})}):n==="unmounted"?null:a(g,{...e,animate:n,animation:n,setAnimation:r})}export{Q as ModalOverlay,Q as default,B as useOverlayAnimationState};
@@ -0,0 +1,52 @@
1
+ # `createAuthAwareGraphqlClient`: a server-side request-headers provider, so SSR calls can carry the shopper
2
+
3
+ **Date:** 2026-09-15
4
+ **Package:** `@clicktap/ui`
5
+ **Affects:** `utils/createAuthAwareGraphqlClient` — new export `setServerRequestHeadersProvider()`; `executeGraphql` and `createAuthAwareFetch` consult it server-side
6
+ **Classification:** FEATURE
7
+ **Breaking:** No. Nothing changes until a provider is registered; explicit request headers always win over provided ones.
8
+ **Ticket:** CTAP-1719
9
+
10
+ ## Why
11
+
12
+ A request the Node server makes to the middleware on a shopper's behalf (SSR, route handlers)
13
+ carried nothing identifying the shopper — the middleware saw the Node container for every visitor.
14
+ Anything keyed on the client there (a rate limit, a lockout) would put the whole storefront in one
15
+ bucket.
16
+
17
+ The fix has to be in the shared client, because that is the one place every server-side request
18
+ passes through — `graphqlRequest`, the quote/checkout transports (`request` and `createFetch`), route
19
+ handlers. But this module is framework-agnostic and reachable from client components, so it cannot
20
+ import `next/headers` itself. Hence a **provider** the app registers:
21
+
22
+ ```ts
23
+ import { setServerRequestHeadersProvider } from '@clicktap/ui/utils/createAuthAwareGraphqlClient';
24
+
25
+ setServerRequestHeadersProvider(() => {
26
+ const forwardedFor = headers().get('x-forwarded-for'); // next/headers, in a server-only module
27
+ return forwardedFor ? { 'x-forwarded-for': forwardedFor } : undefined;
28
+ });
29
+ ```
30
+
31
+ ## Rules
32
+
33
+ - Consulted **only server-side** (`typeof window === 'undefined'`); the browser path is untouched.
34
+ - **Explicit headers win.** A header the caller passed is never overwritten by the provider.
35
+ - **A throwing provider contributes nothing.** `headers()` throws outside a request scope (a static
36
+ render, a warm-up); that is "nothing to forward", not a failed request.
37
+ - Sync or async providers are accepted.
38
+ - Applies to both `createAuthAwareGraphqlClient()` requests and `createAuthAwareFetch()` calls.
39
+
40
+ ## What a consumer does
41
+
42
+ Register a provider from a **server-only** module (`import 'server-only'` + `next/headers`), imported
43
+ by the root layouts and by any route handler that fetches — see the `frontend` guide of the same
44
+ date for the reference module. Forward the incoming `X-Forwarded-For` **verbatim**: the middleware's
45
+ trusted-proxy walk reads it from its own end, so the browser's forged entries are inert and
46
+ "cleaning" the chain here would only remove information.
47
+
48
+ ## Verification
49
+
50
+ ```bash
51
+ nx run ui:test -- createAuthAwareGraphqlClient
52
+ ```
@@ -0,0 +1,71 @@
1
+ # BUGFIX: a closed trigger-driven `ModalOverlay` no longer keeps a detached framer-motion element mounted
2
+
3
+ **Date:** 2026-09-16
4
+ **Package:** `@clicktap/ui`
5
+ **Affects:** `src/components/ModalOverlay/ModalOverlay.tsx` — every trigger-driven `<ModalOverlay>` (no `isOpen` prop, state from `DialogTrigger` / `OverlayTriggerStateContext`). Controlled overlays (`isOpen` given) are unchanged.
6
+ **Classification:** BUGFIX
7
+ **Breaking:** No API change. **Behavioral:** while its trigger is closed and has never opened, the overlay now renders `null` instead of a mounted `motion()` wrapper around an empty react-aria overlay. Nothing consumer-visible was in the DOM before either.
8
+ **Ticket:** CTAP-1729
9
+
10
+ ## Problem
11
+
12
+ `ModalOverlay` has two render paths. The controlled one wraps `InnerModalOverlay` in
13
+ `AnimatePresence` and mounts it only while `isOpen`. The **trigger-driven** one mounted
14
+ `InnerModalOverlay` **unconditionally** — including while `useOverlayAnimationState` reported
15
+ `unmounted`. In that state react-aria's `ModalOverlay` renders nothing (closed, not exiting), but
16
+ our `motion.create(...)` wrapper was still mounted around it: a framer-motion `VisualElement` with
17
+ `element.current === null`, holding `initial="hidden"`, `exit="hidden"` and `animate="unmounted"`
18
+ (which names no variant), for as long as the trigger stayed shut.
19
+
20
+ Any animation that reached that element crashed framer-motion 11.18.2 with
21
+ `TypeError: Cannot read properties of null (reading '0')`: the animation seeds `[null, target]`
22
+ and expects the DOM read to hydrate the first keyframe; `DOMKeyframesResolver` bails on a missing
23
+ element, so `null` survives into `mix(null, 0)` → `mixObject(null, '0')`. Two routes in, both
24
+ observed:
25
+
26
+ 1. **An ancestor `AnimatePresence` exits** and propagates `exit="hidden"` down the subtree.
27
+ 2. **The subtree unmounts** — `VisualElement.unmount` stops the motion values, which flushes
28
+ keyframe resolvers synchronously on the detached element.
29
+
30
+ 🚨 Thrown inside framer-motion's frame loop, so nothing reaches server logs and no React error
31
+ boundary catches it. noco hit it in the warranty admin: a controlled drawer (route 1) containing a
32
+ never-opened trigger-driven Page Builder overlay threw **13 errors on every close**, and in
33
+ production that broke the frame loop — the close animation and the success toast were both lost.
34
+ Every other trigger-driven overlay in that app (delete confirms, edit modals) carried the same
35
+ detached element and only stayed quiet because nothing above them animated on exit yet.
36
+
37
+ ## What changed
38
+
39
+ The trigger-driven path returns `null` while `animation === 'unmounted'` — exactly the DOM
40
+ react-aria was already producing, minus the motion element. The lifecycle the controlled path
41
+ already gets from `AnimatePresence` is now the same on both paths:
42
+
43
+ | trigger | `animation` | rendered |
44
+ |---|---|---|
45
+ | closed, never opened | `unmounted` | `null` |
46
+ | opens | `visible` | `InnerModalOverlay` mounts with `initial="hidden"` → `animate="visible"` (entrance plays) |
47
+ | closes while visible | `hidden` | stays mounted; `isExiting` keeps react-aria's portal alive while the exit plays |
48
+ | exit completes | `unmounted` (via `onAnimationComplete`) | `null` again |
49
+
50
+ ## Consumer impact
51
+
52
+ - **Nothing to change.** Take the release. If you gated the overlay yourself in a wrapper (noco
53
+ tried it in `@noco/ui`), **remove that gate**: mounting only once already open, with the hook
54
+ initialized to `visible`, gives framer-motion no transition to play and leaves the backdrop stuck
55
+ at `opacity: 0`. The fix has to live where the animation state is derived.
56
+ - ⚠️ Do not read the mount as a signal. Nothing should depend on a closed trigger-driven overlay
57
+ having a React subtree — refs into `InnerModalOverlay` are `null` while closed now, as they
58
+ effectively were before (react-aria rendered no node).
59
+
60
+ ## Verification
61
+
62
+ Unit: `ModalOverlay.mount.spec.tsx` mocks `motion.create` to record every render of the wrapper and
63
+ pins the contract — no motion render while closed and never opened; first render on open is
64
+ `initial: 'hidden', animate: 'visible'`; on close the last render is `animate: 'hidden', isExiting:
65
+ true`; after `onAnimationComplete('hidden')` no further renders, including on unrelated re-renders.
66
+ Controlled path covered for parity.
67
+
68
+ Manual, in any app: open a controlled `ModalOverlay` (a drawer) that contains a `DialogTrigger`
69
+ whose `ModalOverlay` you never open; close the drawer. Before: `Cannot read properties of null
70
+ (reading '0')` in the console, one per animated value. After: nothing. Then open the inner overlay
71
+ once and close both — entrance and exit both play.
@@ -1,4 +1,24 @@
1
1
  [
2
+ {
3
+ "path": "2026/09/ui-2026-09-16-modal-overlay-detached-motion-element.md",
4
+ "module": "libs/frontend-modules/ui",
5
+ "package": "@clicktap/ui",
6
+ "date": "2026-09-16",
7
+ "title": "BUGFIX: a closed trigger-driven `ModalOverlay` no longer keeps a detached framer-motion element mounted",
8
+ "classification": "BUGFIX",
9
+ "breaking": "No API change. **Behavioral:** while its trigger is closed and has never opened, the overlay now renders `null` instead of a mounted `motion()` wrapper around an empty react-aria overlay. Nothing consumer-visible was in the DOM before either.",
10
+ "affects": "`src/components/ModalOverlay/ModalOverlay.tsx` — every trigger-driven `<ModalOverlay>` (no `isOpen` prop, state from `DialogTrigger` / `OverlayTriggerStateContext`). Controlled overlays (`isOpen` given) are unchanged."
11
+ },
12
+ {
13
+ "path": "2026/09/ui-2026-09-15-server-request-headers-provider.md",
14
+ "module": "libs/frontend-modules/ui",
15
+ "package": "@clicktap/ui",
16
+ "date": "2026-09-15",
17
+ "title": "`createAuthAwareGraphqlClient`: a server-side request-headers provider, so SSR calls can carry the shopper",
18
+ "classification": "FEATURE",
19
+ "breaking": "No. Nothing changes until a provider is registered; explicit request headers always win over provided ones.",
20
+ "affects": "`utils/createAuthAwareGraphqlClient` — new export `setServerRequestHeadersProvider()`; `executeGraphql` and `createAuthAwareFetch` consult it server-side"
21
+ },
2
22
  {
3
23
  "path": "2026/09/ui-2026-09-09-graphql-field-errors-reported-in-production.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.1.3",
3
+ "version": "2.2.0",
4
4
  "private": false,
5
5
  "author": "Clicktap",
6
6
  "description": "A library of React UI components and low-level hooks.",
@@ -111,6 +111,24 @@ declare class AuthAwareEventBus {
111
111
  * listen, regardless of which call triggered the event).
112
112
  */
113
113
  export declare const authAwareEvents: AuthAwareEventBus;
114
+ /**
115
+ * Headers a SERVER-SIDE caller wants attached to every outbound request, resolved per request.
116
+ *
117
+ * CTAP-1719: a request the Node server makes to the middleware on a shopper's behalf (SSR, route
118
+ * handlers) carried no trace of the shopper — the middleware saw the Node container's address for
119
+ * every visitor, so anything keyed on the client (a rate limit, a lockout) would put the whole
120
+ * storefront in one bucket. The app registers a provider that reads the incoming request's
121
+ * `X-Forwarded-For` (via `next/headers`, which only exists in a request scope) and returns it here,
122
+ * so the middleware's trusted-proxy walk lands on the browser rather than on Node.
123
+ *
124
+ * Registered rather than imported because this module must stay framework-agnostic and bundleable
125
+ * on the client: `next/headers` cannot be imported from anything a client component reaches. The
126
+ * provider is only ever consulted server-side (`typeof window === 'undefined'`); explicit request
127
+ * headers always win over what it returns; and a provider that throws (no request scope — a build-time
128
+ * render, a cron) contributes nothing rather than failing the request.
129
+ */
130
+ export type ServerRequestHeadersProvider = () => HeadersInit | undefined | Promise<HeadersInit | undefined>;
131
+ export declare function setServerRequestHeadersProvider(provider: ServerRequestHeadersProvider | undefined): void;
114
132
  export type AuthAwareGraphqlRequest = <TData, TVariables extends GraphqlVariables = GraphqlVariables>(url: string, document: GraphqlDocument<TData, TVariables>, variables?: TVariables, requestHeaders?: HeadersInit) => Promise<TData>;
115
133
  export type AuthAwareGraphqlRequestWithOptions = <TData, TVariables extends GraphqlVariables = GraphqlVariables>(options: {
116
134
  url: string;
@@ -1 +1 @@
1
- class l extends Error{constructor(t,s){const e=t.errors?.[0]?.message;super(`GraphQL request failed: ${e??`HTTP ${t.status}`}`),this.name="ClientError",this.response=t,this.request=s}}class f{constructor(){this.listeners=new Map}subscribe(t,s){return this.listeners.has(t)||this.listeners.set(t,new Set),this.listeners.get(t).add(s),()=>{this.listeners.get(t)?.delete(s)}}dispatch(t,s){this.listeners.get(t)?.forEach(e=>e(s))}}const u=new f;function p(r,t){r===401?u.dispatch("unauthenticated",t):r===403?u.dispatch("permissionDenied",t):r>=500&&u.dispatch("serverError",t)}function w(r,t,s){if(!s.length)return;const e=r.match(/^\s*(?:query|mutation|subscription)\s+([A-Za-z_][A-Za-z0-9_]*)/)?.[1];for(const n of s){const c=n.path?.join(".")??"unknown",a=`[GraphQL] ${e??"operation"} — field error at ${c}: ${n.message}`;if(process.env.NODE_ENV==="development"){console.error(a,{error:n,request:{operationName:e,variables:t,query:r}});continue}console.error(a,{operationName:e,path:n.path,code:n.extensions?.code,debugMessage:n.extensions?.debugMessage})}}async function d(r){const{url:t,query:s,variables:e,requestHeaders:n,signal:c}=r,a=new Headers(n);a.set("Content-Type","application/json"),a.has("Accept")||a.set("Accept","application/json");const i=await fetch(t,{method:"POST",headers:a,body:JSON.stringify({query:s,variables:e}),signal:c});let o;try{o=await i.json()}catch{o=void 0}if(!i.ok){const h=new l({status:i.status,data:o?.data,errors:o?.errors},{query:s,variables:e});throw p(i.status,{status:i.status,error:h}),h}if(o?.data)return o.errors?.length&&w(s,e,o.errors),o.data;throw new l({status:i.status,errors:o?.errors},{query:s,variables:e})}function g(){const r=async(e,n,c,a)=>d({url:e,query:String(n),variables:c,requestHeaders:a}),t=async e=>d({url:e.url,query:String(e.document),variables:e.variables,requestHeaders:e.requestHeaders,signal:e.signal}),s=r;return s.withOptions=t,s}function y(){return async function(t,s){const e=await fetch(t,s);return(e.status===401||e.status===403||e.status>=500)&&p(e.status,{status:e.status,response:e.clone()}),e}}export{l as ClientError,u as authAwareEvents,y as createAuthAwareFetch,g as createAuthAwareGraphqlClient};
1
+ class l extends Error{constructor(t,s){const e=t.errors?.[0]?.message;super(`GraphQL request failed: ${e??`HTTP ${t.status}`}`),this.name="ClientError",this.response=t,this.request=s}}class v{constructor(){this.listeners=new Map}subscribe(t,s){return this.listeners.has(t)||this.listeners.set(t,new Set),this.listeners.get(t).add(s),()=>{this.listeners.get(t)?.delete(s)}}dispatch(t,s){this.listeners.get(t)?.forEach(e=>e(s))}}const u=new v;function p(r,t){r===401?u.dispatch("unauthenticated",t):r===403?u.dispatch("permissionDenied",t):r>=500&&u.dispatch("serverError",t)}function g(r,t,s){if(!s.length)return;const e=r.match(/^\s*(?:query|mutation|subscription)\s+([A-Za-z_][A-Za-z0-9_]*)/)?.[1];for(const n of s){const a=n.path?.join(".")??"unknown",o=`[GraphQL] ${e??"operation"} — field error at ${a}: ${n.message}`;if(process.env.NODE_ENV==="development"){console.error(o,{error:n,request:{operationName:e,variables:t,query:r}});continue}console.error(o,{operationName:e,path:n.path,code:n.extensions?.code,debugMessage:n.extensions?.debugMessage})}}let d;function y(r){d=r}async function w(){if(!(typeof window<"u"||!d))try{const r=await d();return r?new Headers(r):void 0}catch{return}}async function q(r){return(await w())?.forEach((s,e)=>{r.has(e)||r.set(e,s)}),r}async function f(r){const{url:t,query:s,variables:e,requestHeaders:n,signal:a}=r,o=await q(new Headers(n));o.set("Content-Type","application/json"),o.has("Accept")||o.set("Accept","application/json");const c=await fetch(t,{method:"POST",headers:o,body:JSON.stringify({query:s,variables:e}),signal:a});let i;try{i=await c.json()}catch{i=void 0}if(!c.ok){const h=new l({status:c.status,data:i?.data,errors:i?.errors},{query:s,variables:e});throw p(c.status,{status:c.status,error:h}),h}if(i?.data)return i.errors?.length&&g(s,e,i.errors),i.data;throw new l({status:c.status,errors:i?.errors},{query:s,variables:e})}function E(){const r=async(e,n,a,o)=>f({url:e,query:String(n),variables:a,requestHeaders:o}),t=async e=>f({url:e.url,query:String(e.document),variables:e.variables,requestHeaders:e.requestHeaders,signal:e.signal}),s=r;return s.withOptions=t,s}function A(){return async function(t,s){const e=await w();let n=s;if(e){const o=new Headers(s?.headers);e.forEach((c,i)=>{o.has(i)||o.set(i,c)}),n={...s,headers:o}}const a=await fetch(t,n);return(a.status===401||a.status===403||a.status>=500)&&p(a.status,{status:a.status,response:a.clone()}),a}}export{l as ClientError,u as authAwareEvents,A as createAuthAwareFetch,E as createAuthAwareGraphqlClient,y as setServerRequestHeadersProvider};