@akinon/pz-theme 2.0.152-rc.3 → 2.0.153-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -52
- package/package.json +4 -4
- package/src/blocks/__tests__/add-to-cart-action.test.tsx +1 -0
- package/src/blocks/__tests__/add-to-cart-stock-alert.test.tsx +673 -0
- package/src/blocks/add-to-cart-block.tsx +400 -10
- package/src/blocks/item-template.tsx +19 -25
- package/src/blocks/order-list-block.tsx +2 -25
- package/src/blocks/product-block-utils.tsx +40 -0
- package/src/blocks/stock-alert-pick.ts +106 -0
- package/src/blocks/variant-selector-block.tsx +167 -9
- package/src/chrome/__tests__/page-context-builders.test.ts +1 -14
- package/src/chrome/page-context.ts +0 -4
- package/src/custom/README.md +0 -20
- package/src/overlays/overlay-surface.tsx +21 -12
- package/src/__tests__/row-scope.test.tsx +0 -118
- package/src/blocks/__tests__/order-list-rows.test.tsx +0 -112
- package/src/blocks/row-scope.tsx +0 -35
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useSyncExternalStore } from 'react';
|
|
4
|
+
import { usePathname } from 'next/navigation';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The sold-out option a shopper picked to be told about, handed from the
|
|
8
|
+
* variant selector to the add-to-cart button (ZERO-5233).
|
|
9
|
+
*
|
|
10
|
+
* Commerce makes every in-stock option its own product URL, so picking one is
|
|
11
|
+
* a navigation and the button simply reads the new page's product. A sold-out
|
|
12
|
+
* option has no page to go to (its url redirects away), so "the shopper picked
|
|
13
|
+
* L, which is gone" has to live somewhere both blocks can see without a
|
|
14
|
+
* navigation: here. The two blocks sit in different sections of a composition
|
|
15
|
+
* with no common parent of their own, which is why this is a module store
|
|
16
|
+
* rather than a context.
|
|
17
|
+
*
|
|
18
|
+
* Every pick names the page product it was made on and is only ever read back
|
|
19
|
+
* against that same product. It is also dropped on every navigation (see
|
|
20
|
+
* `useStockAlertPick`), so going to another colour and coming back with the
|
|
21
|
+
* browser's back button never brings an old pick back.
|
|
22
|
+
*
|
|
23
|
+
* It survives one thing on purpose: a guest sent to sign in. The way back
|
|
24
|
+
* carries the pick in `STOCK_ALERT_PICK_PARAM`, and the variant selector picks
|
|
25
|
+
* that option again when the page opens (ZERO-5233).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** The query key the picked sold-out option's product id travels under. */
|
|
29
|
+
export const STOCK_ALERT_PICK_PARAM = 'stock_alert';
|
|
30
|
+
|
|
31
|
+
export interface StockAlertPick {
|
|
32
|
+
/** The page product the pick was made on. */
|
|
33
|
+
pagePk: number;
|
|
34
|
+
/** The sold-out option's own product — what the alert is registered for. */
|
|
35
|
+
productPk: number;
|
|
36
|
+
optionLabel: string;
|
|
37
|
+
attributeKey: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let current: StockAlertPick | null = null;
|
|
41
|
+
const listeners = new Set<() => void>();
|
|
42
|
+
|
|
43
|
+
const notify = () => listeners.forEach((listener) => listener());
|
|
44
|
+
|
|
45
|
+
export const setStockAlertPick = (pick: StockAlertPick | null): void => {
|
|
46
|
+
if (
|
|
47
|
+
current === pick ||
|
|
48
|
+
(current &&
|
|
49
|
+
pick &&
|
|
50
|
+
current.pagePk === pick.pagePk &&
|
|
51
|
+
current.productPk === pick.productPk &&
|
|
52
|
+
current.attributeKey === pick.attributeKey)
|
|
53
|
+
) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
current = pick;
|
|
58
|
+
notify();
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const getStockAlertPick = (): StockAlertPick | null => current;
|
|
62
|
+
|
|
63
|
+
const subscribe = (listener: () => void) => {
|
|
64
|
+
listeners.add(listener);
|
|
65
|
+
|
|
66
|
+
return () => {
|
|
67
|
+
listeners.delete(listener);
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// The server never has a pick: it cannot know what a shopper clicked.
|
|
72
|
+
const getServerSnapshot = (): StockAlertPick | null => null;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The path the store last saw. Module-level rather than a ref, because a
|
|
76
|
+
* navigation can remount every block that reads the pick: a ref would start
|
|
77
|
+
* over on the new page and never see the change.
|
|
78
|
+
*/
|
|
79
|
+
let lastPathname: string | null = null;
|
|
80
|
+
|
|
81
|
+
/** The live pick for `pagePk`, or null when there is none for this page. */
|
|
82
|
+
export const useStockAlertPick = (pagePk: number): StockAlertPick | null => {
|
|
83
|
+
const pathname = usePathname();
|
|
84
|
+
|
|
85
|
+
// A pick belongs to the page it was made on: any navigation drops it.
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (lastPathname !== null && lastPathname !== pathname) {
|
|
88
|
+
setStockAlertPick(null);
|
|
89
|
+
}
|
|
90
|
+
lastPathname = pathname;
|
|
91
|
+
}, [pathname]);
|
|
92
|
+
|
|
93
|
+
const pick = useSyncExternalStore(
|
|
94
|
+
subscribe,
|
|
95
|
+
getStockAlertPick,
|
|
96
|
+
getServerSnapshot
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
return pick && pick.pagePk === pagePk ? pick : null;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const __resetStockAlertPickForTests = () => {
|
|
103
|
+
current = null;
|
|
104
|
+
lastPathname = null;
|
|
105
|
+
listeners.clear();
|
|
106
|
+
};
|
|
@@ -17,10 +17,17 @@ import {
|
|
|
17
17
|
getVariantOptionColor,
|
|
18
18
|
getVariantOptionImage,
|
|
19
19
|
getVariantOptionStock,
|
|
20
|
+
isOptionOfProductSelection,
|
|
20
21
|
pickProductField,
|
|
21
22
|
readToggle,
|
|
22
23
|
useThemeProduct
|
|
23
24
|
} from './product-block-utils';
|
|
25
|
+
import {
|
|
26
|
+
STOCK_ALERT_PICK_PARAM,
|
|
27
|
+
getStockAlertPick,
|
|
28
|
+
setStockAlertPick,
|
|
29
|
+
useStockAlertPick
|
|
30
|
+
} from './stock-alert-pick';
|
|
24
31
|
|
|
25
32
|
const getOptionUrl = (option: ThemeVariantOption): string =>
|
|
26
33
|
String(option?.product?.absolute_url || '').trim();
|
|
@@ -210,7 +217,10 @@ const collapseOptions = <T,>(
|
|
|
210
217
|
*
|
|
211
218
|
* A sold-out option is a dead end by default and stays one unless a brand turns
|
|
212
219
|
* `stockAlert` on: then it becomes a real control that opens a back-in-stock
|
|
213
|
-
* form bound to THAT option's own product id.
|
|
220
|
+
* form bound to THAT option's own product id. With `stockAlertPresentation`
|
|
221
|
+
* set to `add-to-cart` it opens nothing: the option becomes a pick that the
|
|
222
|
+
* page's Add to Cart block (with its own `stockAlert` on) turns into its
|
|
223
|
+
* "notify me" button for that option.
|
|
214
224
|
*
|
|
215
225
|
* Parts: group, label, selected-value, dropdown, option-list, option, swatch,
|
|
216
226
|
* view-all, strike, low-stock, alert-backdrop, alert-panel, alert-title,
|
|
@@ -394,10 +404,16 @@ const VariantSelectorBlock = ({
|
|
|
394
404
|
|
|
395
405
|
// Every word a shopper reads is the brand's, never ours: these markets
|
|
396
406
|
// attach legal text to collecting an email and only the brand knows it.
|
|
407
|
+
// `add-to-cart` draws no form at all: a sold-out option becomes a pick the
|
|
408
|
+
// page's Add to Cart button turns into its own "notify me" (ZERO-5233).
|
|
409
|
+
const alertPresentationValue = alertProp('stockAlertPresentation', 'inline');
|
|
397
410
|
const alertPresentation =
|
|
398
|
-
|
|
399
|
-
|
|
411
|
+
alertPresentationValue === 'modal' ||
|
|
412
|
+
alertPresentationValue === 'add-to-cart'
|
|
413
|
+
? alertPresentationValue
|
|
400
414
|
: 'inline';
|
|
415
|
+
const pagePk = Number(pickProductField(product, 'pk'));
|
|
416
|
+
const stockAlertPick = useStockAlertPick(pagePk);
|
|
401
417
|
const alertTriggerLabel = alertProp(
|
|
402
418
|
'stockAlertTriggerLabel',
|
|
403
419
|
'Notify me when back in stock'
|
|
@@ -532,6 +548,58 @@ const VariantSelectorBlock = ({
|
|
|
532
548
|
const groups = (Array.isArray(rawGroups) ? rawGroups : []).filter(
|
|
533
549
|
(group) => Array.isArray(group?.options) && group.options.length > 0
|
|
534
550
|
);
|
|
551
|
+
const handsAlertToButton =
|
|
552
|
+
stockAlertEnabled && alertPresentation === 'add-to-cart';
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* A guest sent to sign in from "notify me" comes back with the sold-out
|
|
556
|
+
* option they had picked in the url, so the button is back on THAT size and
|
|
557
|
+
* one more press registers it. The key is taken out of the address again so
|
|
558
|
+
* a reload or a shared link does not pick it a second time.
|
|
559
|
+
*/
|
|
560
|
+
React.useEffect(() => {
|
|
561
|
+
if (isDesigner || !handsAlertToButton || typeof window === 'undefined') {
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const params = new URLSearchParams(window.location.search);
|
|
566
|
+
const wanted = Number(params.get(STOCK_ALERT_PICK_PARAM));
|
|
567
|
+
|
|
568
|
+
if (!Number.isFinite(wanted) || wanted <= 0) return;
|
|
569
|
+
|
|
570
|
+
const leaf = groups[groups.length - 1];
|
|
571
|
+
const options = leaf?.options || [];
|
|
572
|
+
const ownSize = (entry: ThemeVariantOption) =>
|
|
573
|
+
isOptionOfProductSelection(entry, product, groups);
|
|
574
|
+
const option = options.find(
|
|
575
|
+
(entry) => getOptionProductPk(entry) === wanted && isOptionSoldOut(entry)
|
|
576
|
+
);
|
|
577
|
+
|
|
578
|
+
if (
|
|
579
|
+
option &&
|
|
580
|
+
!getStockAlertPick() &&
|
|
581
|
+
options.some((entry) => !isOptionSoldOut(entry) && ownSize(entry))
|
|
582
|
+
) {
|
|
583
|
+
setStockAlertPick({
|
|
584
|
+
pagePk,
|
|
585
|
+
productPk: wanted,
|
|
586
|
+
optionLabel: String(option.label || option.value || ''),
|
|
587
|
+
attributeKey: String(leaf?.attribute_key || '')
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
params.delete(STOCK_ALERT_PICK_PARAM);
|
|
592
|
+
const query = params.toString();
|
|
593
|
+
window.history.replaceState(
|
|
594
|
+
window.history.state,
|
|
595
|
+
'',
|
|
596
|
+
`${window.location.pathname}${query ? `?${query}` : ''}${
|
|
597
|
+
window.location.hash
|
|
598
|
+
}`
|
|
599
|
+
);
|
|
600
|
+
// Once per page: the url is read when the page opens, not on every render.
|
|
601
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
602
|
+
}, [pagePk, handsAlertToButton, isDesigner]);
|
|
535
603
|
|
|
536
604
|
if (groups.length === 0) {
|
|
537
605
|
return isDesigner ? (
|
|
@@ -818,9 +886,35 @@ const VariantSelectorBlock = ({
|
|
|
818
886
|
>
|
|
819
887
|
{groups.map((group, groupIndex) => {
|
|
820
888
|
const options = group.options || [];
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
889
|
+
// A sold-out option the shopper picked for the button's alert stands
|
|
890
|
+
// in for the group's selection until they pick something else.
|
|
891
|
+
const pickedKey =
|
|
892
|
+
stockAlertEnabled &&
|
|
893
|
+
alertPresentation === 'add-to-cart' &&
|
|
894
|
+
stockAlertPick?.attributeKey === String(group.attribute_key || '')
|
|
895
|
+
? stockAlertPick.productPk
|
|
896
|
+
: null;
|
|
897
|
+
const isPickedOption = (option: ThemeVariantOption) =>
|
|
898
|
+
pickedKey !== null && getOptionProductPk(option) === pickedKey;
|
|
899
|
+
// Only the LEAF group (the sizes of the colour on the page) hands a
|
|
900
|
+
// pick to the button, and only while one of them is still in stock:
|
|
901
|
+
// that is the same question the button asks before it offers the
|
|
902
|
+
// alert, so the two can never disagree.
|
|
903
|
+
const isLeafGroup = groupIndex === groups.length - 1;
|
|
904
|
+
// Commerce fills a colour's missing sizes with other colours' ones;
|
|
905
|
+
// only the colour's own sizes say whether it has any left.
|
|
906
|
+
const isOwnOption = (option: ThemeVariantOption) =>
|
|
907
|
+
isOptionOfProductSelection(option, product, groups);
|
|
908
|
+
const groupHasStock =
|
|
909
|
+
isLeafGroup &&
|
|
910
|
+
options.some(
|
|
911
|
+
(option) => !isOptionSoldOut(option) && isOwnOption(option)
|
|
912
|
+
);
|
|
913
|
+
const selectedOption =
|
|
914
|
+
options.find(isPickedOption) ||
|
|
915
|
+
(pickedKey === null
|
|
916
|
+
? options.find((option) => option?.is_selected === true)
|
|
917
|
+
: undefined);
|
|
824
918
|
// The <select> is controlled by URL, so an empty value needs a real
|
|
825
919
|
// matching option or React drops the selection on every render.
|
|
826
920
|
const selectedUrl = selectedOption ? getOptionUrl(selectedOption) : '';
|
|
@@ -877,7 +971,7 @@ const VariantSelectorBlock = ({
|
|
|
877
971
|
const label = String(option?.label || option?.value || '');
|
|
878
972
|
const url = getOptionUrl(option);
|
|
879
973
|
const disabled = isOptionDisabled(option);
|
|
880
|
-
const selected = option
|
|
974
|
+
const selected = option === selectedOption;
|
|
881
975
|
const struck = disabled && disabledStyle === 'line-through';
|
|
882
976
|
const showStrike = disabled && disabledStyle === 'diagonal';
|
|
883
977
|
// A number commerce actually sent, above zero (zero is sold out, not
|
|
@@ -1049,7 +1143,63 @@ const VariantSelectorBlock = ({
|
|
|
1049
1143
|
isOptionSoldOut(option) &&
|
|
1050
1144
|
optionPk !== null;
|
|
1051
1145
|
|
|
1052
|
-
|
|
1146
|
+
// With every option of the group gone the button says sold out,
|
|
1147
|
+
// so a pick would offer an alert the button will not register.
|
|
1148
|
+
// A sold-out size commerce filled from another colour is pickable
|
|
1149
|
+
// too, and its alert goes to the product commerce sent for it —
|
|
1150
|
+
// what gant_zero did (`data-notice`), at GANT's request.
|
|
1151
|
+
if (
|
|
1152
|
+
alertable &&
|
|
1153
|
+
alertPresentation === 'add-to-cart' &&
|
|
1154
|
+
groupHasStock
|
|
1155
|
+
) {
|
|
1156
|
+
const groupAttributeKey = String(group.attribute_key || '');
|
|
1157
|
+
|
|
1158
|
+
return (
|
|
1159
|
+
<button
|
|
1160
|
+
key={String(option?.value || optionIndex)}
|
|
1161
|
+
type="button"
|
|
1162
|
+
data-variant-option={selected ? 'picked' : 'disabled'}
|
|
1163
|
+
{...named}
|
|
1164
|
+
// `selected` as well as `disabled`, so the brand's selected
|
|
1165
|
+
// underline lands on the picked size while its sold-out
|
|
1166
|
+
// strike stays drawn.
|
|
1167
|
+
{...partAttrs('option', { disabled: true, selected })}
|
|
1168
|
+
aria-label={[label, alertTriggerLabel]
|
|
1169
|
+
.filter(Boolean)
|
|
1170
|
+
.join(' — ')}
|
|
1171
|
+
title={alertTriggerLabel || label}
|
|
1172
|
+
aria-pressed={selected}
|
|
1173
|
+
onClick={() => {
|
|
1174
|
+
if (isDesigner) return;
|
|
1175
|
+
|
|
1176
|
+
// A second press hands the button back to the page's own
|
|
1177
|
+
// product rather than leaving no way to undo the pick.
|
|
1178
|
+
setStockAlertPick(
|
|
1179
|
+
selected
|
|
1180
|
+
? null
|
|
1181
|
+
: {
|
|
1182
|
+
pagePk,
|
|
1183
|
+
productPk: optionPk,
|
|
1184
|
+
optionLabel: label,
|
|
1185
|
+
attributeKey: groupAttributeKey
|
|
1186
|
+
}
|
|
1187
|
+
);
|
|
1188
|
+
}}
|
|
1189
|
+
style={{
|
|
1190
|
+
...optionStyle,
|
|
1191
|
+
background: 'transparent',
|
|
1192
|
+
font: 'inherit',
|
|
1193
|
+
textAlign: 'center',
|
|
1194
|
+
cursor: 'pointer'
|
|
1195
|
+
}}
|
|
1196
|
+
>
|
|
1197
|
+
{content}
|
|
1198
|
+
</button>
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
if (alertable && alertPresentation !== 'add-to-cart') {
|
|
1053
1203
|
const optionKey = `${groupKey}::${String(
|
|
1054
1204
|
option?.value || optionIndex
|
|
1055
1205
|
)}`;
|
|
@@ -1133,7 +1283,15 @@ const VariantSelectorBlock = ({
|
|
|
1133
1283
|
{...named}
|
|
1134
1284
|
{...partAttrs('option', { selected })}
|
|
1135
1285
|
onClick={(event) => {
|
|
1136
|
-
if (isDesigner)
|
|
1286
|
+
if (isDesigner) {
|
|
1287
|
+
event.preventDefault();
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// Picking a size that IS in stock ends the sold-out pick, even
|
|
1292
|
+
// the one already on the page — its link goes nowhere new, so
|
|
1293
|
+
// no navigation would drop the pick for it.
|
|
1294
|
+
if (stockAlertPick) setStockAlertPick(null);
|
|
1137
1295
|
}}
|
|
1138
1296
|
style={optionStyle}
|
|
1139
1297
|
>
|
|
@@ -41,7 +41,7 @@ const orderPayload = {
|
|
|
41
41
|
orderitem_set: [
|
|
42
42
|
{
|
|
43
43
|
price: '649.95',
|
|
44
|
-
status: {
|
|
44
|
+
status: { label: 'Shipped' },
|
|
45
45
|
product: {
|
|
46
46
|
name: 'Spring Sneaker',
|
|
47
47
|
image: '/i.jpg',
|
|
@@ -63,19 +63,6 @@ describe('buildOrderPageContext', () => {
|
|
|
63
63
|
expect(context.order.currency).toBe('TRY');
|
|
64
64
|
expect(context.order.items).toHaveLength(1);
|
|
65
65
|
expect(context.order.items[0].title).toBe('Spring Sneaker');
|
|
66
|
-
expect(context.order.items[0].status).toBe('Shipped');
|
|
67
|
-
expect(context.order.items[0].statusValue).toBe('500');
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it('stringifies a numeric order item status code', () => {
|
|
71
|
-
const context = buildOrderPageContext({
|
|
72
|
-
order: {
|
|
73
|
-
...orderPayload,
|
|
74
|
-
orderitem_set: [{ status: { value: 550, label: 'Delivered' } }]
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
expect(context.order.items[0].statusValue).toBe('550');
|
|
79
66
|
});
|
|
80
67
|
|
|
81
68
|
it('does not publish the identity fields the raw payload carries', () => {
|
|
@@ -59,9 +59,6 @@ const toRecord = (value: unknown): UnknownRecord =>
|
|
|
59
59
|
const toStringValue = (value: unknown, fallback = ''): string =>
|
|
60
60
|
typeof value === 'string' ? value : fallback;
|
|
61
61
|
|
|
62
|
-
const toCodeValue = (value: unknown): string =>
|
|
63
|
-
typeof value === 'number' ? String(value) : toStringValue(value);
|
|
64
|
-
|
|
65
62
|
const humanizeSlug = (value: string) =>
|
|
66
63
|
value
|
|
67
64
|
.split('-')
|
|
@@ -482,7 +479,6 @@ export const buildOrderPageContext = ({
|
|
|
482
479
|
unitPrice: toStringValue(item.price),
|
|
483
480
|
quantity: 1,
|
|
484
481
|
status: toStringValue(toRecord(item.status).label),
|
|
485
|
-
statusValue: toCodeValue(toRecord(item.status).value),
|
|
486
482
|
currency
|
|
487
483
|
};
|
|
488
484
|
});
|
package/src/custom/README.md
CHANGED
|
@@ -303,26 +303,6 @@ attached". The editor's **Use preview** toggle turns it off while the editor
|
|
|
303
303
|
stays open, so the canvas behaves like the live site: whatever you held open
|
|
304
304
|
closes and links navigate again.
|
|
305
305
|
|
|
306
|
-
## Reading the row a block is placed in
|
|
307
|
-
|
|
308
|
-
A block dropped into a list's row layout (the child a designer marked as the
|
|
309
|
-
row template of an order list, a product grid, an address list, …) renders once
|
|
310
|
-
per row. The product lists open the product scope `useThemeProduct()` reads;
|
|
311
|
-
every list also opens a row scope that carries the row itself:
|
|
312
|
-
|
|
313
|
-
```ts
|
|
314
|
-
import { useThemeRowScope } from '@akinon/pz-theme/src/blocks/row-scope';
|
|
315
|
-
|
|
316
|
-
const scope = useThemeRowScope();
|
|
317
|
-
```
|
|
318
|
-
|
|
319
|
-
Inside a row it returns `{ listType, row, index }`, for example
|
|
320
|
-
`listType: 'order-list'` with the order's `number` and `items` on `row`; anywhere
|
|
321
|
-
else it returns `null`. `row` is the same record the row's bindings resolve
|
|
322
|
-
against, so whatever a text block can bind to (`item.number`), code can read.
|
|
323
|
-
Import it from that deep path for the same reason as `useThemePageContext`: the
|
|
324
|
-
package root is not importable from a client module.
|
|
325
|
-
|
|
326
306
|
## Reporting what happened
|
|
327
307
|
|
|
328
308
|
A custom unit can report a moment — a strip was seen, a lookbook was opened, a
|
|
@@ -325,6 +325,12 @@ export interface OverlayCloseButtonProps {
|
|
|
325
325
|
textSide?: 'before' | 'after';
|
|
326
326
|
/** Legacy marker attribute, e.g. `{ 'data-size-guide-close': '' }`. */
|
|
327
327
|
markerAttrs?: Record<string, string>;
|
|
328
|
+
/**
|
|
329
|
+
* Drawn instead of the built-in cross — a brand's own icon, e.g. the icon
|
|
330
|
+
* font glyph its other drawers close with. Absent draws the cross exactly
|
|
331
|
+
* as before, so `panel-legacy-golden.json` cannot move.
|
|
332
|
+
*/
|
|
333
|
+
icon?: React.ReactNode;
|
|
328
334
|
}
|
|
329
335
|
|
|
330
336
|
/** px unless the author wrote a length. */
|
|
@@ -349,7 +355,8 @@ export const OverlayCloseButton = ({
|
|
|
349
355
|
elevate,
|
|
350
356
|
text,
|
|
351
357
|
textSide,
|
|
352
|
-
markerAttrs
|
|
358
|
+
markerAttrs,
|
|
359
|
+
icon
|
|
353
360
|
}: OverlayCloseButtonProps) => (
|
|
354
361
|
<button
|
|
355
362
|
type="button"
|
|
@@ -394,17 +401,19 @@ export const OverlayCloseButton = ({
|
|
|
394
401
|
: null)
|
|
395
402
|
}}
|
|
396
403
|
>
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
404
|
+
{icon ?? (
|
|
405
|
+
<svg
|
|
406
|
+
width={size === undefined ? '18' : asCssLength(size, '18px')}
|
|
407
|
+
height={size === undefined ? '18' : asCssLength(size, '18px')}
|
|
408
|
+
viewBox="0 0 24 24"
|
|
409
|
+
fill="none"
|
|
410
|
+
stroke="currentColor"
|
|
411
|
+
strokeWidth="1.6"
|
|
412
|
+
strokeLinecap="round"
|
|
413
|
+
>
|
|
414
|
+
<path d="M6 6l12 12M18 6L6 18" />
|
|
415
|
+
</svg>
|
|
416
|
+
)}
|
|
408
417
|
{text ? <span {...partAttrs('close-text')}>{text}</span> : null}
|
|
409
418
|
</button>
|
|
410
419
|
);
|
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { render, screen } from '@testing-library/react';
|
|
3
|
-
import { ItemTemplateList } from '../blocks/item-template';
|
|
4
|
-
import { useThemeProduct } from '../blocks/product-block-utils';
|
|
5
|
-
import { useThemeRowScope } from '../blocks/row-scope';
|
|
6
|
-
import blockRendererRegistry from '../blocks/block-renderer-registry';
|
|
7
|
-
import { resetCustomCatalog } from '../custom/catalog';
|
|
8
|
-
import { resetCustomCollection } from '../custom/collect';
|
|
9
|
-
import { isCustomThemeType, registerCustomBlocks } from '../custom';
|
|
10
|
-
import type { Block } from '../theme-block';
|
|
11
|
-
|
|
12
|
-
const READER = 'custom:acme:row-reader';
|
|
13
|
-
|
|
14
|
-
function RowReader() {
|
|
15
|
-
const scope = useThemeRowScope();
|
|
16
|
-
const product = useThemeProduct();
|
|
17
|
-
|
|
18
|
-
return (
|
|
19
|
-
<span
|
|
20
|
-
data-testid="reader"
|
|
21
|
-
data-list={scope?.listType ?? ''}
|
|
22
|
-
data-index={scope?.index ?? ''}
|
|
23
|
-
data-number={String(scope?.row.number ?? '')}
|
|
24
|
-
data-product={String(product?.pk ?? '')}
|
|
25
|
-
/>
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const template = (): Block =>
|
|
30
|
-
({
|
|
31
|
-
id: 'tpl',
|
|
32
|
-
type: 'group',
|
|
33
|
-
label: 'Row',
|
|
34
|
-
order: 0,
|
|
35
|
-
hidden: false,
|
|
36
|
-
value: '',
|
|
37
|
-
properties: { role: { desktop: 'item-template' } },
|
|
38
|
-
styles: {},
|
|
39
|
-
blocks: [
|
|
40
|
-
{
|
|
41
|
-
id: 'tpl-reader',
|
|
42
|
-
type: READER,
|
|
43
|
-
label: 'Reader',
|
|
44
|
-
order: 0,
|
|
45
|
-
hidden: false,
|
|
46
|
-
value: '',
|
|
47
|
-
properties: {},
|
|
48
|
-
styles: {},
|
|
49
|
-
blocks: []
|
|
50
|
-
}
|
|
51
|
-
]
|
|
52
|
-
} as Block);
|
|
53
|
-
|
|
54
|
-
const list = (type: string): Block =>
|
|
55
|
-
({
|
|
56
|
-
id: 'list',
|
|
57
|
-
type,
|
|
58
|
-
label: 'List',
|
|
59
|
-
order: 0,
|
|
60
|
-
hidden: false,
|
|
61
|
-
value: '',
|
|
62
|
-
properties: {},
|
|
63
|
-
styles: {},
|
|
64
|
-
blocks: [template()]
|
|
65
|
-
} as Block);
|
|
66
|
-
|
|
67
|
-
const renderRows = (type: string, items: Record<string, unknown>[]) =>
|
|
68
|
-
render(
|
|
69
|
-
<ItemTemplateList
|
|
70
|
-
block={list(type)}
|
|
71
|
-
items={items}
|
|
72
|
-
placeholderId="p1"
|
|
73
|
-
sectionId="s1"
|
|
74
|
-
/>
|
|
75
|
-
);
|
|
76
|
-
|
|
77
|
-
beforeAll(() => {
|
|
78
|
-
registerCustomBlocks('acme', { 'row-reader': RowReader });
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
afterAll(() => {
|
|
82
|
-
const map = (
|
|
83
|
-
blockRendererRegistry as unknown as { renderers: Map<string, unknown> }
|
|
84
|
-
).renderers;
|
|
85
|
-
Array.from(map.keys())
|
|
86
|
-
.filter(isCustomThemeType)
|
|
87
|
-
.forEach((key) => map.delete(key));
|
|
88
|
-
resetCustomCatalog();
|
|
89
|
-
resetCustomCollection();
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
describe('the row scope', () => {
|
|
93
|
-
it('hands every repeated row to the blocks inside it, on any list', () => {
|
|
94
|
-
renderRows('order-list', [{ number: 'ORD-1' }, { number: 'ORD-2' }]);
|
|
95
|
-
|
|
96
|
-
const readers = screen.getAllByTestId('reader');
|
|
97
|
-
expect(readers).toHaveLength(2);
|
|
98
|
-
expect(readers[0]).toHaveAttribute('data-list', 'order-list');
|
|
99
|
-
expect(readers[0]).toHaveAttribute('data-index', '0');
|
|
100
|
-
expect(readers[0]).toHaveAttribute('data-number', 'ORD-1');
|
|
101
|
-
expect(readers[1]).toHaveAttribute('data-number', 'ORD-2');
|
|
102
|
-
expect(readers[0]).toHaveAttribute('data-product', '');
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('keeps the product scope on product lists next to the row scope', () => {
|
|
106
|
-
renderRows('product-grid', [{ pk: 7, name: 'Sneaker' }]);
|
|
107
|
-
|
|
108
|
-
const reader = screen.getByTestId('reader');
|
|
109
|
-
expect(reader).toHaveAttribute('data-list', 'product-grid');
|
|
110
|
-
expect(reader).toHaveAttribute('data-product', '7');
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('is closed outside a repeated row', () => {
|
|
114
|
-
render(<RowReader />);
|
|
115
|
-
|
|
116
|
-
expect(screen.getByTestId('reader')).toHaveAttribute('data-list', '');
|
|
117
|
-
});
|
|
118
|
-
});
|