@clicktap/ui 0.40.7 → 0.40.9

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.
Files changed (23) hide show
  1. package/components/Menu/Menu.js +1 -1
  2. package/docs/migrations/2026/01/2026-01-14-external-link-detection.md +290 -0
  3. package/docs/migrations/2026/01/2026-01-14-use-route-hook.md +142 -0
  4. package/docs/migrations/2026/01/2026-01-15-input-disable-skeleton.md +82 -0
  5. package/docs/migrations/2026/02/2026-02-16-env-utils-from-clicktap-ui.md +94 -0
  6. package/docs/migrations/2026/02/2026-02-17-select-popover-portal-container-restored.md +84 -0
  7. package/docs/migrations/2026/03/2026-03-24-image-dimmed-hidden-onready.md +108 -0
  8. package/docs/migrations/2026/03/2026-03-24-modal-overlay-backdrop-dismiss.md +81 -0
  9. package/docs/migrations/2026/04/2026-04-17-select-address-input-onchange-key-or-item.md +182 -0
  10. package/docs/migrations/2026/04/2026-04-24-image-placeholder-frontend-fallback.md +85 -0
  11. package/docs/migrations/2026/04/2026-04-30-prune-invalid-pdp-selections.md +166 -0
  12. package/docs/migrations/2026/04/2026-04-30-top-down-pdp-option-dependency.md +286 -0
  13. package/docs/migrations/2026/05/2026-05-01-build-pdp-url-util.md +128 -0
  14. package/docs/migrations/2026/05/2026-05-03-auth-aware-graphql-client.md +278 -0
  15. package/docs/migrations/2026/05/2026-05-20-framer-motion-11-idioms.md +391 -0
  16. package/docs/migrations/2026/05/2026-05-21-button-loading-content.md +265 -0
  17. package/docs/migrations/2026/05/2026-05-21-password-check-hide-on-empty.md +50 -0
  18. package/docs/migrations/2026/05/2026-05-29-password-check-requirements-variant.md +63 -0
  19. package/docs/migrations/2026/06/2026-06-17-select-standardization.md +286 -0
  20. package/docs/migrations/2026/06/2026-06-18-select-hiddenselect-setvalue-crash.md +75 -0
  21. package/docs/migrations/2026/07/2026-07-31-ui-exports-bundler-resolution.md +50 -0
  22. package/docs/migrations/README.md +31 -0
  23. package/package.json +2 -2
@@ -0,0 +1,85 @@
1
+ # `PLACEHOLDER_IMAGE` Export from `@clicktap/ui/utils/placeholder`
2
+
3
+ **Date:** 2026-04-24
4
+ **Affects:** `@clicktap/ui` consumers that render product/category images
5
+ **Classification:** IMPROVEMENT
6
+ **Breaking:** No
7
+
8
+ ## Dependencies
9
+
10
+ - See [frontend counterpart](2026-04-24-image-placeholder-frontend-fallback.md) ↔ for the consuming-app changes.
11
+
12
+ ## Summary
13
+
14
+ Adds `PLACEHOLDER_IMAGE` — a self-contained data URL — to `@clicktap/ui/utils/placeholder`. Frontend apps render this whenever they have a null image to display, replacing the old "backend returns a placeholder URL" pattern.
15
+
16
+ The value is an inline `data:image/svg+xml;utf8,...` string containing a neutral grayscale "no image available" SVG (camera icon with diagonal slash). Works in every bundler (Next.js, Storybook, Jest, vanilla node) without consumer-side asset pipeline configuration — no `public/` copy, no rewrite, no build script.
17
+
18
+ ## Changes
19
+
20
+ ### New export
21
+
22
+ ```ts
23
+ // libs/ui/src/utils/placeholder.ts
24
+ export const PLACEHOLDER_IMAGE: string;
25
+ ```
26
+
27
+ Import path:
28
+
29
+ ```ts
30
+ import { PLACEHOLDER_IMAGE } from '@clicktap/ui/utils/placeholder';
31
+ ```
32
+
33
+ ### What it looks like
34
+
35
+ The SVG is 200×200, viewBox `0 0 200 200`, with:
36
+ - Light gray background (`#f3f4f6`)
37
+ - Camera icon (rect + viewfinder bump + concentric lens circles), stroke `#9ca3af` weight 4
38
+ - Diagonal slash from upper-right to lower-left at 45° through the camera
39
+
40
+ The body is centered on the canvas (lens at canvas center 100,100); slash passes through canvas center. Symmetric.
41
+
42
+ ## Pattern for Consumers
43
+
44
+ Use directly as the `src` for any image renderer:
45
+
46
+ ```tsx
47
+ import { Image } from '@clicktap/ui/components/Image';
48
+ import { PLACEHOLDER_IMAGE } from '@clicktap/ui/utils/placeholder';
49
+
50
+ <Image
51
+ src={product.image ?? PLACEHOLDER_IMAGE}
52
+ alt={product.name}
53
+ // ...
54
+ />
55
+ ```
56
+
57
+ Works equally well with raw `<img>`:
58
+
59
+ ```tsx
60
+ <img src={product.image ?? PLACEHOLDER_IMAGE} alt="" />
61
+ ```
62
+
63
+ For the PDP/PLP migration, see the [frontend counterpart guide](2026-04-24-image-placeholder-frontend-fallback.md) — it covers the full cascade decision (variant → placeholder, no parent fallback) and the dead `no_selection` checks to remove.
64
+
65
+ ## Files Changed
66
+
67
+ | File | What changed |
68
+ |---|---|
69
+ | `libs/ui/src/utils/placeholder.ts` | New file. Inlines the SVG and exports a data-URL constant. |
70
+ | `libs/ui/src/assets/clicktap-placeholder.svg` | New file. Source of truth for the SVG; the data URL above is a byte-for-byte copy. Keep in sync if the icon changes. |
71
+ | `libs/ui/package.json` | Adds `./assets/*` to the `exports` map (already in place from earlier work; mentioned here for completeness). |
72
+
73
+ ## Migration Checklist
74
+
75
+ - [ ] `@clicktap/ui >= 0.27.0` installed in consumers.
76
+ - [ ] No need to copy any SVG into the consuming app's `public/` folder.
77
+ - [ ] Apply the [frontend counterpart guide](2026-04-24-image-placeholder-frontend-fallback.md) for the consuming-app changes.
78
+
79
+ ## Why a data URL and not a bundled file?
80
+
81
+ Three reasons:
82
+
83
+ 1. **Zero consumer setup.** A bundled file requires consumers to either copy it into `public/` or configure a static-file plugin. A data URL works on import.
84
+ 2. **Bundler-agnostic.** The `@clicktap/ui` package ships to apps using Next.js, Storybook, and Jest test runners. SVG-as-static-asset semantics differ across them; a string constant is identical everywhere.
85
+ 3. **Tiny payload.** The SVG is ~700 bytes; URL-encoded it's ~1KB. Network overhead vs an HTTP-fetched SVG is negligible and we avoid a second request.
@@ -0,0 +1,166 @@
1
+ # PDP Option Selection — Conservative Prune Helper (`pruneInvalidSelections`)
2
+
3
+ **Date:** 2026-04-30
4
+ **Affects:** `@clicktap/ui/utils/variantOptions` — adds `pruneInvalidSelections`
5
+ **Classification:** IMPROVEMENT
6
+ **Breaking:** No (additive)
7
+
8
+ ## Dependencies
9
+
10
+ - Builds on [`2026-04-30-top-down-pdp-option-dependency.md`](./2026-04-30-top-down-pdp-option-dependency.md). That migration introduced top-down option filtering with a blanket "clear every downstream selection on any upstream change". This migration replaces the recommended consumer pattern with a more conservative prune that preserves still-valid downstream picks.
11
+ - Frontend counterpart: [`apps/frontend/.../2026-04-30-prune-invalid-pdp-selections.md`](2026-04-30-prune-invalid-pdp-selections.md) ↔.
12
+
13
+ ## Summary
14
+
15
+ Adds `pruneInvalidSelections(variants, selections, axes)` to `@clicktap/ui/utils/variantOptions`. Walks the selection map top-to-bottom and drops only entries whose value is no longer reachable under the (possibly already-pruned) upstream context. Preserves user intent: a downstream pick is dropped only when actually invalid, never just because an upstream sibling changed.
16
+
17
+ `getDownstreamAxisCodes` is kept exported for convenience but the recommended `setUserSelection` pattern now uses `pruneInvalidSelections` instead.
18
+
19
+ ## Why
20
+
21
+ The previous `setUserSelection` pattern (from the earlier 2026-04-30 migration) blanket-cleared every axis downstream of the changed one. That's correct, but it discards user intent in a common case:
22
+
23
+ - User has `Color=Red, Size=Large, Material=Cotton`.
24
+ - User changes `Color` to `Blue`.
25
+ - If `Blue+Large+Cotton` is a real variant, blanket-clear still drops both `Size` and `Material` and forces the user to repick.
26
+
27
+ `pruneInvalidSelections` keeps still-valid downstream picks. Three behaviors:
28
+
29
+ | Scenario | Blanket clear (old) | Prune (new) |
30
+ |---|---|---|
31
+ | `Blue+Large+Cotton` is a real variant | Drops Size and Material | Keeps both |
32
+ | `Blue+Large` exists but `Blue+Large+Cotton` doesn't | Drops Size and Material | Keeps Size, drops Material |
33
+ | `Blue+Large` doesn't exist | Drops Size and Material | Drops Size and Material |
34
+
35
+ ## Public API
36
+
37
+ ```ts
38
+ import {
39
+ pruneInvalidSelections,
40
+ filterAvailableOptions,
41
+ findVariant,
42
+ getDownstreamAxisCodes,
43
+ } from '@clicktap/ui/utils/variantOptions';
44
+ ```
45
+
46
+ ```ts
47
+ /**
48
+ * Walks `selections` top-to-bottom along `axes` and drops any whose value
49
+ * is no longer reachable under the (possibly already-pruned) upstream
50
+ * context.
51
+ *
52
+ * Validates the entire selection map, not just downstream of one axis —
53
+ * works for both selection mutation and URL/state rehydration.
54
+ *
55
+ * Returns the same object reference when nothing was dropped, so callers
56
+ * can use `===` to skip downstream state updates.
57
+ */
58
+ function pruneInvalidSelections<V extends VariantLike>(
59
+ variants: V[],
60
+ selections: Record<string, string>,
61
+ axes: string[],
62
+ ): Record<string, string>;
63
+ ```
64
+
65
+ Implementation properties worth knowing:
66
+
67
+ - **Top-to-bottom iteration**: each axis evaluates against axes earlier in `axes` that have already been validated this pass. Cascades through naturally — if an upstream pick gets dropped, downstream is re-checked against the now-empty upstream slot.
68
+ - **Object identity preserved**: returns `selections` unchanged when nothing is dropped. Lets `useState` setters skip downstream re-renders via referential equality.
69
+ - **Conservative**: a downstream value is dropped only when its reachable set (under current upstream) doesn't contain it. Independence between sibling axes is preserved. E.g. switching `Color: Red→Blue` keeps `Material: Cotton` even when `Size` was dropped, as long as `Blue+(any size)+Cotton` exists.
70
+
71
+ ## Pattern for Consumers
72
+
73
+ Replace `getDownstreamAxisCodes` + manual delete in your selection handler with `pruneInvalidSelections` over the full candidate map. **Read the previous selections from the closure and run all side effects in event-handler scope — do NOT use the functional-updater form of setState here**:
74
+
75
+ ```tsx
76
+ import { pruneInvalidSelections } from '@clicktap/ui/utils/variantOptions';
77
+
78
+ const setSelection = useCallback(
79
+ (axisCode: string, optionCode: string) => {
80
+ if (selections[axisCode] === optionCode) {
81
+ return;
82
+ }
83
+ const candidate = { ...selections, [axisCode]: optionCode };
84
+ // Prune cascades: top-to-bottom, each axis reads pruned upstream.
85
+ const next = pruneInvalidSelections(variants, candidate, axisCodes);
86
+
87
+ setSelections(next);
88
+ writeSelectionsToUrl(next); // or any other side effect
89
+ },
90
+ [axisCodes, selections, variants],
91
+ );
92
+ ```
93
+
94
+ > ⚠ **Don't put side effects inside a `setSelections((prev) => ...)` updater.**
95
+ > React requires functional updaters to be pure — no calls that schedule
96
+ > updates on other components, no mutations. Calling `router.replace()` (or
97
+ > any side effect that dispatches into another component's render cycle)
98
+ > from inside an updater triggers `"Cannot update a component while
99
+ > rendering a different component"` and a recoverable crash on
100
+ > first-select / deselect interactions. React 19 enforces this harder.
101
+ > Read the previous state from the closure and put side effects after the
102
+ > setter, in the event-handler scope. Trade-off: closure-read loses
103
+ > stale-closure protection if multiple selections fire in the same
104
+ > event-loop tick — fine for swatch clicks paced by humans, but if you
105
+ > wire a programmatic batch-set-multiple-axes flow on top, defer the side
106
+ > effects to a `useEffect` keyed on `selections` and bring back the
107
+ > functional updater for the state write.
108
+
109
+ If your component tracks per-axis manual-clear flags (so auto-collapse doesn't re-fill an axis the user explicitly cleared), only unflag the axes that the prune actually dropped:
110
+
111
+ ```tsx
112
+ const candidate = { ...selections, [axisCode]: optionCode };
113
+ const next = pruneInvalidSelections(variants, candidate, axisCodes);
114
+
115
+ for (const code of axisCodes) {
116
+ if (candidate[code] !== undefined && next[code] === undefined) {
117
+ manuallyClearedAxes.current.delete(code);
118
+ }
119
+ }
120
+
121
+ setSelections(next);
122
+ writeSelectionsToUrl(next);
123
+ ```
124
+
125
+ Otherwise, a kept (still-valid) selection could later get auto-collapsed back to a value the user previously cleared.
126
+
127
+ ## Migration Steps
128
+
129
+ This is purely additive — existing code keeps compiling. To adopt:
130
+
131
+ ### 1. Bump `@clicktap/ui`
132
+
133
+ ```bash
134
+ pnpm up @clicktap/ui
135
+ ```
136
+
137
+ Minimum version: **next minor after the release that ships this migration**.
138
+
139
+ ### 2. Replace blanket clear with prune in selection handlers
140
+
141
+ Find every call site that uses `getDownstreamAxisCodes` to clear downstream selections:
142
+
143
+ ```bash
144
+ grep -rn 'getDownstreamAxisCodes' apps/ libs/ --include='*.ts' --include='*.tsx'
145
+ ```
146
+
147
+ Replace the manual delete loop with a single `pruneInvalidSelections` call (see "Pattern for Consumers" above). `getDownstreamAxisCodes` itself stays exported for any caller that genuinely needs the codes for non-prune purposes.
148
+
149
+ ### Verification
150
+
151
+ - **Behavior**: change an upstream axis on a configurable PDP. Confirm a downstream selection that's still reachable (i.e. the new combination is a real variant) stays selected. Confirm a downstream selection that's now unreachable gets dropped.
152
+ - **Lib tests**: `nx test ui --testPathPattern variantOptions` → 26 tests pass (was 15).
153
+ - **Typecheck**: `nx run frontend:typecheck` (or your app equivalent) → no errors.
154
+
155
+ ## Files Changed
156
+
157
+ | File | Change |
158
+ |---|---|
159
+ | `libs/ui/src/utils/variantOptions/pruneInvalidSelections.ts` | New — top-to-bottom prune helper |
160
+ | `libs/ui/src/utils/variantOptions/pruneInvalidSelections.test.ts` | New — Jest coverage (11 tests) |
161
+ | `libs/ui/src/utils/variantOptions/index.ts` | Export `pruneInvalidSelections` |
162
+
163
+ ## References
164
+
165
+ - Frontend counterpart migration: [`apps/frontend/.../2026-04-30-prune-invalid-pdp-selections.md`](2026-04-30-prune-invalid-pdp-selections.md) ↔
166
+ - Previous top-down migration this builds on: [`2026-04-30-top-down-pdp-option-dependency.md`](./2026-04-30-top-down-pdp-option-dependency.md)
@@ -0,0 +1,286 @@
1
+ # Top-Down PDP Option Dependency — `@clicktap/ui/utils/variantOptions`
2
+
3
+ **Date:** 2026-04-30
4
+ **Affects:** New utility module `@clicktap/ui/utils/variantOptions` (ships generic `filterAvailableOptions`, `findVariant`, `getDownstreamAxisCodes`)
5
+ **Classification:** IMPROVEMENT
6
+ **Breaking:** No (additive)
7
+
8
+ ## Dependencies
9
+
10
+ - Builds on the per-axis selection model used in [`apps/frontend/.../2026-04-24-pdp-configurable-selector-ux.md`](../../../../../../apps/frontend/docs/migrations/2026/04/2026-04-24-pdp-configurable-selector-ux.md). The previous symmetric filter lived in the frontend app; that logic now lives here in generic, reusable form. See [`apps/frontend/.../2026-04-30-top-down-pdp-option-dependency.md`](2026-04-30-top-down-pdp-option-dependency.md) ↔ for the frontend-side migration steps that adopt the new utilities and switch to top-down semantics.
11
+
12
+ ## Summary
13
+
14
+ Adds a small reusable module — `@clicktap/ui/utils/variantOptions` — for any consumer that builds a configurable-PDP variant picker. Three pure functions:
15
+
16
+ 1. **`filterAvailableOptions(variants, selections, axes, axisCode)`** — top-down: returns option codes selectable for `axisCode` given only the *upstream* selections (axes earlier in `axes`). Downstream selections never constrain.
17
+ 2. **`findVariant(variants, selections)`** — first variant whose attributes match every non-empty selection; matches by option `code` or `id`.
18
+ 3. **`getDownstreamAxisCodes(axes, axisCode)`** — axis codes after `axisCode` in `axes`. Useful for clearing dependent selections when an upstream axis changes.
19
+
20
+ All three are generic (`<V extends VariantLike>`) and operate on a minimal structural shape so consumers don't have to flatten their domain types. Pure, no React dependency, no side effects.
21
+
22
+ ## Why top-down
23
+
24
+ Symmetric filtering — selecting any axis filters every other axis equally — is what most early implementations do. It feels coherent until the user wants to change a higher-priority pick: every dependent dropdown is now scoped by their previous lower picks, and they can't re-explore the upstream space without manually clearing each lower selection first.
25
+
26
+ Top-down inverts the relationship: the *first* axis is always fully open, the *second* is filtered by the first, and so on. Changing the first axis is free. The cost is that the consumer must clear lower selections when an upstream changes (their value may not exist in the new scope) — `getDownstreamAxisCodes` makes that mechanical.
27
+
28
+ This module ships the filter logic only. The selection-state mechanics (clearing downstream when upstream changes, URL sync, manual-clear guards) stay in the consumer app since those are wired into framework-specific state and routing.
29
+
30
+ ## Changes
31
+
32
+ ### New module
33
+
34
+ **Source:** `libs/ui/src/utils/variantOptions/`
35
+
36
+ ```
37
+ libs/ui/src/utils/variantOptions/
38
+ ├── filterAvailableOptions.ts
39
+ ├── filterAvailableOptions.test.ts
40
+ ├── findVariant.ts
41
+ ├── findVariant.test.ts
42
+ ├── getDownstreamAxisCodes.ts
43
+ ├── getDownstreamAxisCodes.test.ts
44
+ ├── index.ts
45
+ └── types.ts
46
+ ```
47
+
48
+ ### Public API
49
+
50
+ ```ts
51
+ import {
52
+ filterAvailableOptions,
53
+ findVariant,
54
+ getDownstreamAxisCodes,
55
+ type VariantLike,
56
+ type VariantAttribute,
57
+ } from '@clicktap/ui/utils/variantOptions';
58
+ ```
59
+
60
+ ### Type contract
61
+
62
+ The minimum structural shape required by every function:
63
+
64
+ ```ts
65
+ // libs/ui/src/utils/variantOptions/types.ts
66
+ export interface VariantAttribute {
67
+ attribute: { code: string };
68
+ option: { code: string; id?: string | null };
69
+ }
70
+
71
+ export interface VariantLike {
72
+ attributes: VariantAttribute[];
73
+ }
74
+ ```
75
+
76
+ Real-world variants typically carry far more (price, image, sku, …). Pass them in as-is — the generic constraint only requires `attributes`.
77
+
78
+ ### `filterAvailableOptions`
79
+
80
+ ```ts
81
+ export function filterAvailableOptions<V extends VariantLike>(
82
+ variants: V[],
83
+ selections: Record<string, string>,
84
+ axes: string[],
85
+ axisCode: string,
86
+ ): Set<string>
87
+ ```
88
+
89
+ - `axes` is the contract: top-down order, upstream → downstream. The caller decides ordering (typically via the attribute's `sortPriority` from the backend).
90
+ - For `axisCode` at index `i` in `axes`, only `selections[axes[0]]…selections[axes[i-1]]` constrain. Selections on `axes[i+1]…` are ignored.
91
+ - `axisCode === axes[0]` (or any axis not in `axes`) returns the full reachable set across `variants` for that code.
92
+ - Empty-string and `undefined` selections are treated as unconstrained.
93
+
94
+ ### `findVariant`
95
+
96
+ ```ts
97
+ export function findVariant<V extends VariantLike>(
98
+ variants: V[],
99
+ selections: Record<string, string>,
100
+ ): V | undefined
101
+ ```
102
+
103
+ - Empty/missing selections per axis are unconstrained.
104
+ - A selection matches if it equals either the option's `code` or `id` — supports callers that key by either.
105
+
106
+ ### `getDownstreamAxisCodes`
107
+
108
+ ```ts
109
+ export function getDownstreamAxisCodes(
110
+ axes: string[],
111
+ axisCode: string,
112
+ ): string[]
113
+ ```
114
+
115
+ - Returns `axes.slice(idx + 1)` where `idx = axes.indexOf(axisCode)`.
116
+ - Returns `[]` if `axisCode` isn't in `axes` (defensive — caller may pass a non-axis code).
117
+
118
+ ## Pattern for Consumers
119
+
120
+ A minimal PDP variant picker built on these utilities looks like this:
121
+
122
+ ```tsx
123
+ import { useMemo, useCallback, useState } from 'react';
124
+ import {
125
+ filterAvailableOptions,
126
+ findVariant,
127
+ getDownstreamAxisCodes,
128
+ } from '@clicktap/ui/utils/variantOptions';
129
+
130
+ type Variant = {
131
+ id: string;
132
+ attributes: Array<{
133
+ attribute: { code: string };
134
+ option: { code: string; id?: string };
135
+ }>;
136
+ // ...your domain fields
137
+ };
138
+
139
+ type Props = {
140
+ // Top-down ordered axes — upstream first.
141
+ axes: { code: string; label: string; options: { code: string; label: string }[] }[];
142
+ variants: Variant[];
143
+ };
144
+
145
+ export function VariantPicker({ axes, variants }: Props) {
146
+ const axisCodes = useMemo(() => axes.map((a) => a.code), [axes]);
147
+ const [selections, setSelections] = useState<Record<string, string>>({});
148
+
149
+ const setSelection = useCallback(
150
+ (axisCode: string, optionCode: string) => {
151
+ setSelections((prev) => {
152
+ // Top-down: clear any selection downstream of this axis. Their value
153
+ // may not exist in the new upstream scope.
154
+ const downstream = getDownstreamAxisCodes(axisCodes, axisCode);
155
+ const next: Record<string, string> = { ...prev, [axisCode]: optionCode };
156
+ for (const code of downstream) {
157
+ delete next[code];
158
+ }
159
+ return next;
160
+ });
161
+ },
162
+ [axisCodes],
163
+ );
164
+
165
+ const matchedVariant = useMemo(
166
+ () => findVariant(variants, selections),
167
+ [variants, selections],
168
+ );
169
+
170
+ return (
171
+ <div>
172
+ {axes.map((axis) => {
173
+ const reachable = filterAvailableOptions(
174
+ variants,
175
+ selections,
176
+ axisCodes,
177
+ axis.code,
178
+ );
179
+ return (
180
+ <div key={axis.code}>
181
+ <label>{axis.label}</label>
182
+ {axis.options
183
+ .filter((o) => reachable.has(o.code))
184
+ .map((o) => (
185
+ <button
186
+ key={o.code}
187
+ aria-pressed={selections[axis.code] === o.code}
188
+ onClick={() => setSelection(axis.code, o.code)}
189
+ >
190
+ {o.label}
191
+ </button>
192
+ ))}
193
+ </div>
194
+ );
195
+ })}
196
+ {matchedVariant && <p>Matched: {matchedVariant.id}</p>}
197
+ </div>
198
+ );
199
+ }
200
+ ```
201
+
202
+ Annotations:
203
+ - The `setSelections` callback is the entire top-down rule — clear downstream on any upstream change. Skip this and the picker behaves as a buggy version of symmetric filtering.
204
+ - `filterAvailableOptions` is called once per axis on each render. Cheap — O(variants × attributes).
205
+ - The pattern intentionally doesn't cover URL sync, manual-clear guards, or auto-collapse. Those are framework- and routing-specific; layer them on top.
206
+
207
+ ## Migration Steps
208
+
209
+ This module is purely additive — there's no migration *required* of existing `@clicktap/ui` consumers. The steps below are for consumers who want to adopt it.
210
+
211
+ ### 1. Bump `@clicktap/ui`
212
+
213
+ Once a release including this module is published, update the dependency:
214
+
215
+ ```bash
216
+ pnpm up @clicktap/ui
217
+ ```
218
+
219
+ Minimum version: **next minor after 0.27.2** (set the explicit floor once released).
220
+
221
+ ### 2. Import where needed
222
+
223
+ ```ts
224
+ import {
225
+ filterAvailableOptions,
226
+ findVariant,
227
+ getDownstreamAxisCodes,
228
+ } from '@clicktap/ui/utils/variantOptions';
229
+ ```
230
+
231
+ ### 3. Pass top-down axis order
232
+
233
+ Wherever you call `filterAvailableOptions`, pass the axes array in upstream-to-downstream order. The natural source is your attribute list sorted by priority — for backends following the Clicktap shape, that's `sortPriority DESC`:
234
+
235
+ ```ts
236
+ const axisCodes = product.attributes.map((a) => a.code);
237
+ // product.attributes is already sorted by sortPriority DESC server-side.
238
+
239
+ const reachable = filterAvailableOptions(
240
+ product.variants,
241
+ userSelections,
242
+ axisCodes,
243
+ attribute.code,
244
+ );
245
+ ```
246
+
247
+ ### 4. Clear downstream on upstream change
248
+
249
+ When the user picks a value for any axis, drop any downstream selections in the same state update:
250
+
251
+ ```ts
252
+ const setUserSelection = (axisCode: string, optionCode: string) => {
253
+ setSelections((prev) => {
254
+ const downstream = getDownstreamAxisCodes(axisCodes, axisCode);
255
+ const next = { ...prev, [axisCode]: optionCode };
256
+ for (const code of downstream) {
257
+ delete next[code];
258
+ }
259
+ return next;
260
+ });
261
+ };
262
+ ```
263
+
264
+ Skip this and you'll occasionally see the matched variant query against an impossible (upstream-changed but downstream-stale) selection set.
265
+
266
+ ### Verification
267
+
268
+ - New axis renders all reachable options regardless of downstream state.
269
+ - Picking the upper axis clears the lower selections.
270
+ - Picking the lowest axis doesn't change which upper options are reachable.
271
+
272
+ ## Files Changed
273
+
274
+ | File | Change |
275
+ |---|---|
276
+ | `libs/ui/src/utils/variantOptions/types.ts` | New — `VariantLike`, `VariantAttribute` |
277
+ | `libs/ui/src/utils/variantOptions/filterAvailableOptions.ts` | New — top-down filter |
278
+ | `libs/ui/src/utils/variantOptions/findVariant.ts` | New — generic match |
279
+ | `libs/ui/src/utils/variantOptions/getDownstreamAxisCodes.ts` | New — slice helper |
280
+ | `libs/ui/src/utils/variantOptions/index.ts` | New — barrel export |
281
+ | `libs/ui/src/utils/variantOptions/*.test.ts` | New — Jest coverage (15 tests) |
282
+
283
+ ## References
284
+
285
+ - Frontend counterpart migration: [`apps/frontend/.../2026-04-30-top-down-pdp-option-dependency.md`](2026-04-30-top-down-pdp-option-dependency.md) ↔
286
+ - Prior symmetric-filter implementation it replaces: [`apps/frontend/.../2026-04-24-pdp-configurable-selector-ux.md`](../../../../../../apps/frontend/docs/migrations/2026/04/2026-04-24-pdp-configurable-selector-ux.md)
@@ -0,0 +1,128 @@
1
+ # `buildPdpUrl` Utility — `@clicktap/ui/utils/buildPdpUrl`
2
+
3
+ **Date:** 2026-05-01
4
+ **Affects:** `@clicktap/ui/utils/buildPdpUrl` — new export
5
+ **Classification:** IMPROVEMENT
6
+ **Breaking:** No (additive)
7
+
8
+ ## Dependencies
9
+
10
+ *None.*
11
+
12
+ ## Summary
13
+
14
+ Adds `buildPdpUrl(canonicalUrl, typeOptions)` to `@clicktap/ui/utils/buildPdpUrl`. Encodes a configurable item's variant selections onto its PDP canonical URL as `?attributeCode=optionCode` pairs, matching `ProductTypeResolver.writeSelectionsToUrl` so deep-linking from a cart/order item lands on the exact variant.
15
+
16
+ ## Why
17
+
18
+ Cart and order item rows hold both a canonical URL (e.g. `/everyday-backpack`) and a list of selected attributes (e.g. `color: orange`, `size: m`). Linking with just the canonical URL drops the variant context — the customer lands on the PDP with no swatch pre-selected and has to re-pick. Encoding the selections onto the link mirrors what the PDP itself writes back to the URL on swatch change, so navigating into a PDP from a cart/order row produces the same URL as if the user had configured the PDP themselves.
19
+
20
+ The helper was originally inlined under `apps/frontend/components/Checkout/Success/Items/buildPdpUrl.ts`, used only by the order-success page. Promoting it to `@clicktap/ui` makes it reusable from the cart drawer (and any future consumer that pairs a canonical URL with a `typeOptions`-shaped attribute list).
21
+
22
+ ## Public API
23
+
24
+ ```ts
25
+ import { buildPdpUrl } from '@clicktap/ui/utils/buildPdpUrl';
26
+ ```
27
+
28
+ ```ts
29
+ /**
30
+ * Append configurable-product variant selections to a PDP canonical URL as
31
+ * `?attributeCode=optionCode` pairs (e.g. `?color=black`).
32
+ *
33
+ * Accepts the structural shape that any cart-item / order-item / quote-item
34
+ * `typeOptions` produces on the configurable case.
35
+ *
36
+ * Returns the URL unchanged when:
37
+ * - the URL is null/empty,
38
+ * - the item isn't configurable (no attributes), or
39
+ * - the existing URL already carries a query string (we don't merge —
40
+ * the item's own variant context is authoritative, and bailing keeps
41
+ * caller intent clear).
42
+ */
43
+ function buildPdpUrl(
44
+ canonicalUrl: string | null | undefined,
45
+ typeOptions: unknown,
46
+ ): string | null;
47
+ ```
48
+
49
+ The `typeOptions` parameter is intentionally typed `unknown` and read structurally as `{ attributes?: Array<{ attribute?: { code? }, option?: { code? } }> }`. This lets the same helper accept both `OrderItemTypeOptions` and `QuoteItemTypeOptions` (and any other GraphQL union with the same shape) without forcing the lib to import the consumer's generated types.
50
+
51
+ ## Behavior summary
52
+
53
+ | Input | Output |
54
+ |---|---|
55
+ | `(null, ...)` | `null` |
56
+ | `('', ...)` | `null` |
57
+ | `('/foo', null)` | `'/foo'` |
58
+ | `('/foo', { attributes: [] })` | `'/foo'` |
59
+ | `('/foo', { attributes: [{ attribute: { code: 'color' }, option: { code: 'red' } }] })` | `'/foo?color=red'` |
60
+ | `('/foo', { attributes: [{ color }, { size }] })` | `'/foo?color=red&size=m'` |
61
+ | `('/foo?bar=1', { attributes: [...] })` | `'/foo?bar=1'` (URL has a query — bail rather than merge) |
62
+
63
+ ## Pattern for Consumers
64
+
65
+ Use it wherever a cart/order item row links to its PDP and you want the variant pre-selected:
66
+
67
+ ```tsx
68
+ import { Link } from '@clicktap/ui/components/Link';
69
+ import { buildPdpUrl } from '@clicktap/ui/utils/buildPdpUrl';
70
+
71
+ function CartItemRow({ item }: { item: QuoteItem }) {
72
+ const pdpUrl = buildPdpUrl(item.product.canonicalUrl, item.typeOptions) ?? '';
73
+ return (
74
+ <Link href={pdpUrl}>
75
+ {item.name}
76
+ </Link>
77
+ );
78
+ }
79
+ ```
80
+
81
+ The function is structural, so `item.typeOptions` doesn't need to be cast — pass it directly.
82
+
83
+ ## Migration Steps
84
+
85
+ This is purely additive — existing code keeps compiling. To adopt:
86
+
87
+ ### 1. Bump `@clicktap/ui`
88
+
89
+ ```bash
90
+ pnpm up @clicktap/ui
91
+ ```
92
+
93
+ Minimum version: **next minor after the release that ships this migration**.
94
+
95
+ ### 2. Replace any inlined copies with the shared util
96
+
97
+ Search consumer projects for inlined copies that predate this migration:
98
+
99
+ ```bash
100
+ grep -rn 'buildPdpUrl' apps/ libs/ --include='*.ts' --include='*.tsx'
101
+ ```
102
+
103
+ Switch each call site to:
104
+
105
+ ```ts
106
+ import { buildPdpUrl } from '@clicktap/ui/utils/buildPdpUrl';
107
+ ```
108
+
109
+ If the consumer project had its own typed-to-`OrderItemTypeOptions` (or any other concrete) signature, the structural `unknown` parameter accepts it without any cast change at the call site — the existing arguments compile as-is.
110
+
111
+ ### 3. (Optional) Wire it into cart/order link components
112
+
113
+ Anywhere a cart/order row currently links via `product.canonicalUrl` directly, swap to `buildPdpUrl(product.canonicalUrl, typeOptions)`. Make sure your GraphQL selection actually fetches `canonicalUrl` and the `typeOptions { ... attributes { attribute { code }, option { code } } }` block — without those, the helper returns the URL unchanged and the variant won't pre-select.
114
+
115
+ ### Verification
116
+
117
+ - **Behavior**: open a cart drawer (or order success page) for a configurable product with attributes set, click the item link. The PDP should load with the swatches matching the variant pre-selected.
118
+ - **Typecheck**: `nx run <app>:typecheck` → no errors.
119
+
120
+ ## Files Changed
121
+
122
+ | File | Change |
123
+ |---|---|
124
+ | `libs/ui/src/utils/buildPdpUrl.ts` | New — structural helper |
125
+
126
+ ## References
127
+
128
+ - PDP encoding source of truth: `apps/frontend/components/Product/ProductType/.../ProductTypeResolver.writeSelectionsToUrl`