@clicktap/ui 0.40.8 → 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.
- package/docs/migrations/2026/01/2026-01-14-external-link-detection.md +290 -0
- package/docs/migrations/2026/01/2026-01-14-use-route-hook.md +142 -0
- package/docs/migrations/2026/01/2026-01-15-input-disable-skeleton.md +82 -0
- package/docs/migrations/2026/02/2026-02-16-env-utils-from-clicktap-ui.md +94 -0
- package/docs/migrations/2026/02/2026-02-17-select-popover-portal-container-restored.md +84 -0
- package/docs/migrations/2026/03/2026-03-24-image-dimmed-hidden-onready.md +108 -0
- package/docs/migrations/2026/03/2026-03-24-modal-overlay-backdrop-dismiss.md +81 -0
- package/docs/migrations/2026/04/2026-04-17-select-address-input-onchange-key-or-item.md +182 -0
- package/docs/migrations/2026/04/2026-04-24-image-placeholder-frontend-fallback.md +85 -0
- package/docs/migrations/2026/04/2026-04-30-prune-invalid-pdp-selections.md +166 -0
- package/docs/migrations/2026/04/2026-04-30-top-down-pdp-option-dependency.md +286 -0
- package/docs/migrations/2026/05/2026-05-01-build-pdp-url-util.md +128 -0
- package/docs/migrations/2026/05/2026-05-03-auth-aware-graphql-client.md +278 -0
- package/docs/migrations/2026/05/2026-05-20-framer-motion-11-idioms.md +391 -0
- package/docs/migrations/2026/05/2026-05-21-button-loading-content.md +265 -0
- package/docs/migrations/2026/05/2026-05-21-password-check-hide-on-empty.md +50 -0
- package/docs/migrations/2026/05/2026-05-29-password-check-requirements-variant.md +63 -0
- package/docs/migrations/2026/06/2026-06-17-select-standardization.md +286 -0
- package/docs/migrations/2026/06/2026-06-18-select-hiddenselect-setvalue-crash.md +75 -0
- package/docs/migrations/2026/07/2026-07-31-ui-exports-bundler-resolution.md +50 -0
- package/docs/migrations/README.md +31 -0
- package/package.json +1 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Image: dimmed, hidden, and onReady Props
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-03-24
|
|
4
|
+
**Affects:** `@clicktap/ui` — `Image` component
|
|
5
|
+
**Classification:** IMPROVEMENT
|
|
6
|
+
**Breaking:** No
|
|
7
|
+
**Group:** plp-image-transitions
|
|
8
|
+
|
|
9
|
+
## Dependencies
|
|
10
|
+
|
|
11
|
+
*None*
|
|
12
|
+
|
|
13
|
+
See also: [Collection Image Filter Transitions](../../../../../../apps/frontend/docs/migrations/2026/03/2026-03-24-collection-image-filter-transitions.md) ↔ — frontend implementation using these props
|
|
14
|
+
|
|
15
|
+
## Summary
|
|
16
|
+
|
|
17
|
+
The `Image` component gains three optional props for opacity-based loading transitions: `dimmed` (50% opacity), `hidden` (0% opacity), and `onReady` (callback when a new image loads after src change). When none of these props are passed, the component behaves exactly as before (blur-based loading). This also fixes a flicker bug caused by `onError` firing on valid images.
|
|
18
|
+
|
|
19
|
+
## Problem
|
|
20
|
+
|
|
21
|
+
Two issues existed:
|
|
22
|
+
|
|
23
|
+
1. **No way to coordinate image transitions** — collection grids needed to dim/hide images during filter changes to prevent stale variant images from flashing. The `Image` component had no props for external opacity control.
|
|
24
|
+
|
|
25
|
+
2. **onError flicker** — Next.js image optimization can fire `onError` on valid images when one `srcSet` variant fails while another succeeds. The immediate `setErrorSrc` call caused a re-render that produced a visible flicker. Additionally, `key={srcKey}` on the error boundary caused a full component remount on every src change.
|
|
26
|
+
|
|
27
|
+
## Changes
|
|
28
|
+
|
|
29
|
+
### New props on `ExtendedImageProps`
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
export type ExtendedImageProps = ImageProps & {
|
|
33
|
+
/** Reduce opacity to 50% (e.g., while waiting for data) */
|
|
34
|
+
dimmed?: boolean;
|
|
35
|
+
/** Hide image completely — opacity 0 (e.g., before grid rearrange) */
|
|
36
|
+
hidden?: boolean;
|
|
37
|
+
/** Fires when a new image has loaded after a src change */
|
|
38
|
+
onReady?: () => void;
|
|
39
|
+
};
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Source**: `libs/ui/src/components/Image/Image.tsx:62-69`
|
|
43
|
+
|
|
44
|
+
### Managed vs unmanaged mode
|
|
45
|
+
|
|
46
|
+
When `dimmed`, `hidden`, or `onReady` are passed ("managed" mode):
|
|
47
|
+
- Opacity transitions replace the blur loading effect
|
|
48
|
+
- `opacity: 0` when hidden, `0.5` when loading or dimmed, `1` when loaded
|
|
49
|
+
- Src changes are detected during render (not `useEffect`) to prevent stale frame flash
|
|
50
|
+
- `onReady` fires when a new image finishes loading
|
|
51
|
+
|
|
52
|
+
When none are passed ("unmanaged" mode):
|
|
53
|
+
- Existing blur-based loading (`blur-md`) is preserved
|
|
54
|
+
- No opacity management, no src change detection
|
|
55
|
+
- Fully backward compatible
|
|
56
|
+
|
|
57
|
+
### onError debounce (bugfix)
|
|
58
|
+
|
|
59
|
+
`onError` now uses a 2-second debounced timeout. If `onLoad` fires within that window (which it does for valid images), the timeout is cleared and no error fallback occurs.
|
|
60
|
+
|
|
61
|
+
**Before:**
|
|
62
|
+
```tsx
|
|
63
|
+
onError={() => setErrorSrc(src)}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**After:**
|
|
67
|
+
```tsx
|
|
68
|
+
onError={() => {
|
|
69
|
+
errorTimer.current = setTimeout(() => setErrorSrc(src), 2000);
|
|
70
|
+
}}
|
|
71
|
+
onLoad={(e) => {
|
|
72
|
+
clearTimeout(errorTimer.current);
|
|
73
|
+
setLoadingImg(false);
|
|
74
|
+
// ...
|
|
75
|
+
}}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Error boundary key removal (bugfix)
|
|
79
|
+
|
|
80
|
+
The `key={srcKey}` on `ImageErrorBoundary` has been removed. It was causing a full component remount on every src change, which destroyed state and produced flicker. The error boundary's `errorSrc` state check already handles src changes correctly.
|
|
81
|
+
|
|
82
|
+
## Migration Steps
|
|
83
|
+
|
|
84
|
+
No action required — these are additive, non-breaking changes. Existing `Image` usage is unaffected.
|
|
85
|
+
|
|
86
|
+
To use the new props:
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
import { Image } from '@clicktap/ui/components/Image';
|
|
90
|
+
|
|
91
|
+
<Image
|
|
92
|
+
alt="Product"
|
|
93
|
+
src={imageUrl}
|
|
94
|
+
width={500}
|
|
95
|
+
height={625}
|
|
96
|
+
dimmed={isLoading} // optional: 50% opacity
|
|
97
|
+
hidden={isHidden} // optional: 0% opacity
|
|
98
|
+
onReady={() => onLoaded()} // optional: callback when loaded
|
|
99
|
+
/>
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Migration Checklist
|
|
103
|
+
|
|
104
|
+
- [ ] Verify existing `Image` usage still works (blur loading, error fallback)
|
|
105
|
+
- [ ] Verify `dimmed` prop reduces opacity to 50%
|
|
106
|
+
- [ ] Verify `hidden` prop hides image completely (opacity 0)
|
|
107
|
+
- [ ] Verify `onReady` fires after src change + image load
|
|
108
|
+
- [ ] Verify no flicker on images that previously flickered with `onError`
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# ModalOverlay: Fix Dismiss on Backdrop Click
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-03-24
|
|
4
|
+
**Affects:** `@clicktap/ui` — `ModalOverlay` component
|
|
5
|
+
**Classification:** BUGFIX
|
|
6
|
+
**Breaking:** No
|
|
7
|
+
|
|
8
|
+
## Dependencies
|
|
9
|
+
|
|
10
|
+
*None*
|
|
11
|
+
|
|
12
|
+
## Summary
|
|
13
|
+
|
|
14
|
+
Clicking the backdrop overlay behind a drawer or modal did not dismiss it, even with `isDismissable` set. Pressing Escape and the close button worked correctly. The fix adds an explicit `onPointerDown` handler to the `ModalOverlay` that closes when the user clicks directly on the backdrop.
|
|
15
|
+
|
|
16
|
+
## Problem
|
|
17
|
+
|
|
18
|
+
react-aria's `useInteractOutside` hook detects clicks **outside** a ref element to trigger dismiss. However, the `ModalOverlay` component covers the entire viewport — so every click, including on the backdrop, is considered "inside" the overlay ref. The dismiss never fires for backdrop clicks.
|
|
19
|
+
|
|
20
|
+
The `Modal` component already had `pointer-events-none [&>*]:pointer-events-auto` to let clicks pass through to the overlay, but react-aria's event detection still could not distinguish backdrop clicks from content clicks.
|
|
21
|
+
|
|
22
|
+
## Changes
|
|
23
|
+
|
|
24
|
+
**File**: `libs/ui/src/components/ModalOverlay/ModalOverlay.tsx`
|
|
25
|
+
|
|
26
|
+
The `InnerModalOverlay` component now:
|
|
27
|
+
|
|
28
|
+
1. Extracts `onOpenChange` from props (previously spread via `...restProps`)
|
|
29
|
+
2. Adds a `handlePointerDown` callback that calls `onOpenChange(false)` when the click target is the overlay element itself (`e.target === e.currentTarget`)
|
|
30
|
+
3. Passes both `onOpenChange` and `onPointerDown` to the overlay element
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
const {
|
|
34
|
+
key,
|
|
35
|
+
shouldCloseOnInteractOutside: propShouldClose,
|
|
36
|
+
onOpenChange,
|
|
37
|
+
...restProps
|
|
38
|
+
} = props;
|
|
39
|
+
|
|
40
|
+
// react-aria's useInteractOutside checks clicks outside the overlay ref,
|
|
41
|
+
// but since the overlay covers the viewport, backdrop clicks are "inside"
|
|
42
|
+
// and never trigger dismiss. Handle backdrop clicks explicitly.
|
|
43
|
+
const handlePointerDown = useCallback(
|
|
44
|
+
(e: PointerEvent<HTMLDivElement>) => {
|
|
45
|
+
if (isDismissable && e.target === e.currentTarget && onOpenChange) {
|
|
46
|
+
onOpenChange(false);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
[isDismissable, onOpenChange],
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const commonProps = {
|
|
53
|
+
isDismissable,
|
|
54
|
+
shouldCloseOnInteractOutside,
|
|
55
|
+
onOpenChange,
|
|
56
|
+
onPointerDown: handlePointerDown,
|
|
57
|
+
// ...rest
|
|
58
|
+
};
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Source**: `libs/ui/src/components/ModalOverlay/ModalOverlay.tsx:139-183`
|
|
62
|
+
|
|
63
|
+
The `e.target === e.currentTarget` check ensures that only clicks directly on the backdrop trigger dismiss — clicks on child elements (the drawer/dialog content) are ignored because `e.target` would be a descendant, not the overlay itself.
|
|
64
|
+
|
|
65
|
+
## Migration Steps
|
|
66
|
+
|
|
67
|
+
1. Update `libs/ui/src/components/ModalOverlay/ModalOverlay.tsx`:
|
|
68
|
+
- Add `PointerEvent` to the React type imports
|
|
69
|
+
- Destructure `onOpenChange` from props alongside `key` and `shouldCloseOnInteractOutside`
|
|
70
|
+
- Add the `handlePointerDown` callback
|
|
71
|
+
- Add `onOpenChange` and `onPointerDown: handlePointerDown` to `commonProps`
|
|
72
|
+
|
|
73
|
+
2. Verify by opening any drawer (e.g., cart drawer) and clicking the dark backdrop area — it should now close.
|
|
74
|
+
|
|
75
|
+
## Migration Checklist
|
|
76
|
+
|
|
77
|
+
- [ ] Update `ModalOverlay.tsx` with the `handlePointerDown` handler
|
|
78
|
+
- [ ] Verify drawers dismiss on backdrop click
|
|
79
|
+
- [ ] Verify clicking inside a drawer does **not** dismiss it
|
|
80
|
+
- [ ] Verify Escape key still dismisses
|
|
81
|
+
- [ ] Verify close button still works
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# Select / AddressInput: `onSelectionChange` / `onChange` callback widened to `Key | T`
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-04-17 *(documentation date; underlying react-aria upgrade landed 2026-01-19 via commit `7c27599b2a`)*
|
|
4
|
+
**Affects:** Frontend apps consuming `@clicktap/ui`'s `Select` and components that re-export its types (`AddressInput`, etc.). Any handler typed as `(value: Key) => void` breaks after the update.
|
|
5
|
+
**Classification:** COMPATIBILITY
|
|
6
|
+
**Breaking:** Yes — TypeScript-only. No runtime change; existing handlers still receive the same value at runtime, but strict TS setups fail to compile.
|
|
7
|
+
|
|
8
|
+
## Dependencies
|
|
9
|
+
|
|
10
|
+
| Package | Minimum version |
|
|
11
|
+
|---|---|
|
|
12
|
+
| `@clicktap/ui` | The release that bundles `react-aria-components ^1.12.x` (commit `7c27599b2a`). Confirm with `pnpm why react-aria-components` that the resolved version is `>= 1.12.0`. |
|
|
13
|
+
|
|
14
|
+
## Summary
|
|
15
|
+
|
|
16
|
+
`@clicktap/ui`'s `Select<T>` extends `AriaComboBoxProps<T>`. After the react-aria upgrade, the `onSelectionChange` argument type widened from `Key | null` to effectively `Key | null | T` — callers can now receive either the selected item's key **or**, in certain configurations, the full item object, depending on how the consumer sets up `items` and `selectedKey`.
|
|
17
|
+
|
|
18
|
+
For consumer handlers that were strictly typed `(key: Key) => void`, TypeScript now complains that `Key | T` is not assignable to `Key`. The fix is to narrow at the callback entry point.
|
|
19
|
+
|
|
20
|
+
## Consumer Impact
|
|
21
|
+
|
|
22
|
+
Types changed:
|
|
23
|
+
|
|
24
|
+
| Type | Before | After |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `SelectProps<T>['onSelectionChange']` | `(key: Key \| null) => void` | `(key: Key \| null \| T) => void` (widened union) |
|
|
27
|
+
| `AddressInputProps['onChange']` (in `apps/frontend/components/AddressForm/AddressInput`) | Re-typed to `(address?: AddressSuggestion) => void` — consumer-owned. Still works after upgrade if the inner `onSelectionChange` handler narrows correctly. |
|
|
28
|
+
|
|
29
|
+
Find affected handlers:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Handlers passed to Select or wrappers that assume `Key` only
|
|
33
|
+
grep -rn 'onSelectionChange\|onChange=' apps/frontend/ | \
|
|
34
|
+
grep -E 'Select|AddressInput|Combobox|Dropdown' | \
|
|
35
|
+
head -40
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Preconditions
|
|
39
|
+
|
|
40
|
+
- [ ] `@clicktap/ui` is pinned to the release containing commit `7c27599b2a` or later. Check with:
|
|
41
|
+
```bash
|
|
42
|
+
jq '.dependencies["@clicktap/ui"]' apps/frontend/package.json
|
|
43
|
+
```
|
|
44
|
+
- [ ] `apps/frontend/package.json` is tracked in git. Some legacy project layouts gitignore it — de-ignore before applying changes so CI/typecheck runs see the fix.
|
|
45
|
+
|
|
46
|
+
## Migration Steps
|
|
47
|
+
|
|
48
|
+
### 1. Narrow the argument in your handler
|
|
49
|
+
|
|
50
|
+
The cleanest fix is a type guard at the callback boundary:
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
// Before — TS error after upgrade
|
|
54
|
+
<Select
|
|
55
|
+
items={items}
|
|
56
|
+
onSelectionChange={(key: Key) => {
|
|
57
|
+
selectItemByKey(key);
|
|
58
|
+
}}
|
|
59
|
+
/>
|
|
60
|
+
|
|
61
|
+
// After — narrow the widened union
|
|
62
|
+
<Select
|
|
63
|
+
items={items}
|
|
64
|
+
onSelectionChange={(keyOrItem) => {
|
|
65
|
+
const key = typeof keyOrItem === 'string' || typeof keyOrItem === 'number'
|
|
66
|
+
? keyOrItem
|
|
67
|
+
: keyOrItem?.id; // or whatever your T's key field is
|
|
68
|
+
if (key === null || key === undefined) return;
|
|
69
|
+
selectItemByKey(key);
|
|
70
|
+
}}
|
|
71
|
+
/>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
> **For consumers with 10+ call sites:** the per-site narrow above gets repetitive. Consider extracting a local helper to your project's `utils/` module:
|
|
75
|
+
>
|
|
76
|
+
> ```ts
|
|
77
|
+
> import type { Key } from 'react-aria-components';
|
|
78
|
+
>
|
|
79
|
+
> export function narrowKey<T extends { id: Key }>(
|
|
80
|
+
> value: Key | T | null,
|
|
81
|
+
> ): Key | null {
|
|
82
|
+
> if (value === null) return null;
|
|
83
|
+
> if (typeof value === 'string' || typeof value === 'number') return value;
|
|
84
|
+
> return value.id;
|
|
85
|
+
> }
|
|
86
|
+
> ```
|
|
87
|
+
>
|
|
88
|
+
> Then each call site becomes `onSelectionChange={(v) => { const key = narrowKey(v); if (key !== null) selectItemByKey(key); }}`. We may upstream this helper to `@clicktap/ui/utils` in a future release; for now, ship it locally if your codebase has enough call sites to justify the abstraction.
|
|
89
|
+
|
|
90
|
+
### 2. If you need the full item, accept it directly
|
|
91
|
+
|
|
92
|
+
When a handler wants the full `T`, skip the narrow and type against it:
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
type Option = { id: Key; label: string };
|
|
96
|
+
|
|
97
|
+
<Select<Option>
|
|
98
|
+
items={options}
|
|
99
|
+
onSelectionChange={(value) => {
|
|
100
|
+
// value is Key | null | Option — narrow by shape
|
|
101
|
+
const option = typeof value === 'object' && value !== null ? value : null;
|
|
102
|
+
if (option) onOptionSelected(option);
|
|
103
|
+
}}
|
|
104
|
+
/>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### 3. For wrappers like `AddressInput`, narrow inside the wrapper once
|
|
108
|
+
|
|
109
|
+
If your app wraps `Select` (the clicktap `AddressInput` does this), narrow in one place so every consumer of the wrapper keeps its old `(address?: T) => void` contract:
|
|
110
|
+
|
|
111
|
+
```tsx
|
|
112
|
+
// apps/frontend/components/AddressForm/AddressInput/AddressInput.tsx
|
|
113
|
+
<Select<AddressSuggestion>
|
|
114
|
+
items={suggestions}
|
|
115
|
+
onSelectionChange={(keyOrItem) => {
|
|
116
|
+
const selection = typeof keyOrItem === 'object' && keyOrItem !== null
|
|
117
|
+
? keyOrItem
|
|
118
|
+
: suggestions.find((s) => s.key === keyOrItem);
|
|
119
|
+
props.onChange?.(selection);
|
|
120
|
+
}}
|
|
121
|
+
/>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Verification
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
# Typecheck should pass with zero errors mentioning onSelectionChange
|
|
128
|
+
nx run frontend:typecheck
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Consumer-Project Workaround
|
|
132
|
+
|
|
133
|
+
If for any reason the consumer project can't yet take the new `@clicktap/ui` release but must compile:
|
|
134
|
+
|
|
135
|
+
1. **Pin the previous minor** in `apps/frontend/package.json`:
|
|
136
|
+
```json
|
|
137
|
+
"@clicktap/ui": "~X.Y.Z"
|
|
138
|
+
```
|
|
139
|
+
Replace `X.Y.Z` with the last minor before the react-aria 3.43.x bump.
|
|
140
|
+
|
|
141
|
+
2. **Or wrap locally** until ready — shadow `Select` in a local component that narrows the callback before forwarding:
|
|
142
|
+
```tsx
|
|
143
|
+
// apps/frontend/components/LegacySelect.tsx
|
|
144
|
+
import { Select as UpstreamSelect, type SelectProps } from '@clicktap/ui/components/Select';
|
|
145
|
+
import type { Key } from 'react-aria-components';
|
|
146
|
+
|
|
147
|
+
type LegacySelectProps<T extends { id: Key }> = Omit<SelectProps<T>, 'onSelectionChange'> & {
|
|
148
|
+
onSelectionChange?: (key: Key | null) => void;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export function LegacySelect<T extends { id: Key }>(props: LegacySelectProps<T>) {
|
|
152
|
+
return (
|
|
153
|
+
<UpstreamSelect<T>
|
|
154
|
+
{...props}
|
|
155
|
+
onSelectionChange={(v) => {
|
|
156
|
+
const key = typeof v === 'object' && v !== null ? v.id : v;
|
|
157
|
+
props.onSelectionChange?.(key ?? null);
|
|
158
|
+
}}
|
|
159
|
+
/>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Prefer the narrow-in-handler fix over the wrapper — the wrapper is only for bulk migrations where touching every call site at once isn't feasible.
|
|
165
|
+
|
|
166
|
+
## Rollback
|
|
167
|
+
|
|
168
|
+
Revert `@clicktap/ui` in `apps/frontend/package.json`:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
docker compose exec -u app node-app pnpm --filter frontend add '@clicktap/ui@<previous-minor>'
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Safe — TypeScript-only change. No persistent state involved.
|
|
175
|
+
|
|
176
|
+
## References
|
|
177
|
+
|
|
178
|
+
- Upstream commit: `7c27599b2a` — `refactor(ui): upgrade react-aria and refactor Select component`
|
|
179
|
+
- react-aria upgrade: `react-aria ^3.43.1`, `react-aria-components ^1.12.2`
|
|
180
|
+
- Existing related guide: `docs/migrations/2026-01-14-checkout-fixes.md` (partially documents this upgrade; expand with the onSelectionChange narrowing pattern above)
|
|
181
|
+
- Source: `libs/ui/src/components/Select/Select.types.ts`
|
|
182
|
+
- Wrapper reference: `apps/frontend/components/AddressForm/AddressInput/AddressInput.types.ts`
|
|
@@ -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)
|