@akinon/pz-theme 2.0.36-beta.0 → 2.0.36
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 +5 -4
- package/jest/__mocks__/hookform-resolvers.js +5 -0
- package/jest/__mocks__/next-auth-react.js +5 -0
- package/jest.config.js +3 -0
- package/package.json +4 -4
- package/src/blocks/accordion-block.tsx +35 -3
- package/src/blocks/commerce-format.ts +220 -0
- package/src/blocks/icon-block.tsx +6 -1
- package/src/blocks/image-gallery-block.tsx +1 -1
- package/src/blocks/link-block.tsx +9 -1
- package/src/blocks/slider-block.tsx +2 -1
- package/src/blocks/tab-block.tsx +15 -2
- package/src/blocks/video-block.tsx +6 -0
- package/src/components/__tests__/part-state-preview-scope.test.tsx +148 -0
- package/src/components/action-toolbar.tsx +182 -3
- package/src/components/part-state-preview.tsx +92 -0
- package/src/hooks/__tests__/use-overlay.test.tsx +503 -0
- package/src/hooks/use-designer-features.tsx +16 -1
- package/src/hooks/use-overlay.ts +538 -0
- package/src/hooks/use-visibility-context.ts +14 -2
- package/src/sections/__tests__/divider-section.test.tsx +28 -1
- package/src/sections/contact-form-section.tsx +4 -1
- package/src/sections/coupon-banner-section.tsx +2 -1
- package/src/sections/featured-product-spotlight-section.tsx +4 -4
- package/src/sections/find-in-store-section.tsx +2 -1
- package/src/sections/hover-showcase-section.tsx +2 -1
- package/src/sections/installment-options-section.tsx +2 -1
- package/src/sections/order-tracking-lookup-section.tsx +6 -1
- package/src/sections/posts-slider-section.tsx +8 -3
- package/src/sections/pre-order-launch-banner-section.tsx +2 -1
- package/src/sections/tabs-section.tsx +7 -13
- package/src/theme-block.tsx +81 -16
- package/src/theme-placeholder-client.tsx +8 -1
- package/src/theme-placeholder-wrapper.tsx +17 -3
- package/src/theme-placeholder.tsx +51 -5
- package/src/theme-section.tsx +20 -4
- package/src/utils/__tests__/breakpoint-cascade.test.ts +351 -0
- package/src/utils/__tests__/group-box-chrome.test.ts +83 -0
- package/src/utils/__tests__/iterator-bindings.test.tsx +1178 -0
- package/src/utils/__tests__/part-ownership-guard.test.tsx +298 -0
- package/src/utils/breakpoints.ts +116 -0
- package/src/utils/custom-css.ts +233 -0
- package/src/utils/index.ts +353 -42
- package/src/utils/iterator-utils.ts +675 -62
- package/src/utils/page-context-discovery.test.ts +188 -0
- package/src/utils/page-context-discovery.ts +48 -4
- package/src/utils/part-styles.ts +229 -0
- package/src/utils/row-visibility.ts +273 -0
- package/src/utils/visibility-rules.test.ts +579 -0
- package/src/utils/visibility-rules.ts +346 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.36
|
|
3
|
+
## 2.0.36
|
|
4
4
|
|
|
5
5
|
### Patch Changes
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
- @akinon/theme-contract@2.0.36
|
|
7
|
+
- 72be24d3: ZERO-4706: Add the theme render foundations — a tablet breakpoint, named parts with state styling, per-row bindings and conditions, one overlay contract, and a scoped custom-CSS hatch
|
|
8
|
+
- Updated dependencies [72be24d3]
|
|
9
|
+
- @akinon/theme-contract@2.0.36
|
|
10
|
+
- @akinon/next@2.0.36
|
|
10
11
|
|
|
11
12
|
## 2.0.35
|
|
12
13
|
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Mock for next-auth/react: it ships ESM that jest will not transform, and the
|
|
2
|
+
// blocks that reach for it only ask whether somebody is signed in.
|
|
3
|
+
const useSession = () => ({ data: null, status: 'unauthenticated' });
|
|
4
|
+
|
|
5
|
+
module.exports = { useSession, signIn: () => {}, signOut: () => {} };
|
package/jest.config.js
CHANGED
|
@@ -15,6 +15,9 @@ module.exports = {
|
|
|
15
15
|
'\\.(css|less|scss|sass)$': '<rootDir>/jest/style-mock.js',
|
|
16
16
|
'^settings$': '<rootDir>/jest/__mocks__/settings.js',
|
|
17
17
|
'^next/link$': '<rootDir>/jest/__mocks__/next-link.js',
|
|
18
|
+
'^next-auth/react$': '<rootDir>/jest/__mocks__/next-auth-react.js',
|
|
19
|
+
'^@hookform/resolvers(/.*)?$':
|
|
20
|
+
'<rootDir>/jest/__mocks__/hookform-resolvers.js',
|
|
18
21
|
'^@akinon/next/hooks$': '<rootDir>/jest/__mocks__/akinon-next-hooks.js',
|
|
19
22
|
'^@akinon/next/hooks/use-localization$':
|
|
20
23
|
'<rootDir>/jest/__mocks__/akinon-next-hooks.js',
|
package/package.json
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akinon/pz-theme",
|
|
3
3
|
"description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
|
|
4
|
-
"version": "2.0.36
|
|
4
|
+
"version": "2.0.36",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"test": "jest"
|
|
9
9
|
},
|
|
10
10
|
"peerDependencies": {
|
|
11
|
-
"@akinon/next": "2.0.36
|
|
11
|
+
"@akinon/next": "2.0.36",
|
|
12
12
|
"react": "^18.0.0 || ^19.0.0",
|
|
13
13
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@akinon/theme-contract": "2.0.36
|
|
16
|
+
"@akinon/theme-contract": "2.0.36",
|
|
17
17
|
"clsx": "2.1.1",
|
|
18
18
|
"lottie-web": "^5.13.0",
|
|
19
19
|
"tailwind-merge": "^2.5.4",
|
|
20
20
|
"zod": "4.4.1"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@akinon/next": "2.0.36
|
|
23
|
+
"@akinon/next": "2.0.36",
|
|
24
24
|
"@testing-library/jest-dom": "5.16.4",
|
|
25
25
|
"@testing-library/react": "16.3.2",
|
|
26
26
|
"@testing-library/user-event": "14.1.1",
|
|
@@ -1,7 +1,26 @@
|
|
|
1
1
|
import React, { useState } from 'react';
|
|
2
2
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
3
3
|
import ThemeBlock from '../theme-block';
|
|
4
|
+
import { partAttrs } from '../utils';
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Accordion: a heading row that toggles a content panel.
|
|
8
|
+
*
|
|
9
|
+
* Whether the accordion was open used to exist only as inline styles the
|
|
10
|
+
* renderer computed for itself — a rotated icon child and a collapsed grid
|
|
11
|
+
* row — and never as anything a stylesheet could match. So the near-universal
|
|
12
|
+
* "+ becomes −" was inexpressible: not as a part style, and not even from
|
|
13
|
+
* scoped custom CSS, because the markup offered no hook to select on. The
|
|
14
|
+
* header, the panel and the content box now carry `data-state="open"` while
|
|
15
|
+
* the accordion is expanded, which is the same vocabulary every other block
|
|
16
|
+
* uses (see utils/part-styles.ts) and which `[data-state~="open"]` matches.
|
|
17
|
+
*
|
|
18
|
+
* The heading and content children are authored blocks with their own ids and
|
|
19
|
+
* their own style panels; the open state reaches them as a descendant of
|
|
20
|
+
* `[data-part="header"][data-state~="open"]`.
|
|
21
|
+
*
|
|
22
|
+
* Parts: header, panel, content, empty.
|
|
23
|
+
*/
|
|
5
24
|
const AccordionBlock: React.FC<BlockRendererProps> = ({
|
|
6
25
|
block,
|
|
7
26
|
placeholderId,
|
|
@@ -20,7 +39,10 @@ const AccordionBlock: React.FC<BlockRendererProps> = ({
|
|
|
20
39
|
|
|
21
40
|
if (!headingBlock && !contentBlock) {
|
|
22
41
|
return (
|
|
23
|
-
<div
|
|
42
|
+
<div
|
|
43
|
+
{...partAttrs('empty')}
|
|
44
|
+
className="p-4 text-gray-400 border border-dashed border-gray-300 rounded"
|
|
45
|
+
>
|
|
24
46
|
No accordion content
|
|
25
47
|
</div>
|
|
26
48
|
);
|
|
@@ -116,18 +138,28 @@ const AccordionBlock: React.FC<BlockRendererProps> = ({
|
|
|
116
138
|
|
|
117
139
|
return (
|
|
118
140
|
<>
|
|
119
|
-
<div
|
|
141
|
+
<div
|
|
142
|
+
{...partAttrs('header', { open: isOpen })}
|
|
143
|
+
style={{ cursor: 'pointer' }}
|
|
144
|
+
onClick={toggleAccordion}
|
|
145
|
+
>
|
|
120
146
|
{renderHeading()}
|
|
121
147
|
</div>
|
|
122
148
|
|
|
123
149
|
<div
|
|
150
|
+
{...partAttrs('panel', { open: isOpen })}
|
|
124
151
|
style={{
|
|
125
152
|
display: 'grid',
|
|
126
153
|
gridTemplateRows: isOpen ? '1fr' : '0fr',
|
|
127
154
|
transition: 'grid-template-rows 0.3s ease-in-out'
|
|
128
155
|
}}
|
|
129
156
|
>
|
|
130
|
-
<div
|
|
157
|
+
<div
|
|
158
|
+
{...partAttrs('content', { open: isOpen })}
|
|
159
|
+
style={{ overflow: 'hidden' }}
|
|
160
|
+
>
|
|
161
|
+
{renderContent()}
|
|
162
|
+
</div>
|
|
131
163
|
</div>
|
|
132
164
|
</>
|
|
133
165
|
);
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One money/date formatter for every commerce block.
|
|
3
|
+
*
|
|
4
|
+
* Three families of blocks each grew their own price format, so the same
|
|
5
|
+
* product read differently on the PDP, the grid and the basket. Two of
|
|
6
|
+
* them also called Intl with an implicit locale, which resolves to the
|
|
7
|
+
* Node default on the server and the browser locale on the client — a
|
|
8
|
+
* hydration mismatch waiting for the basket page context to be wired.
|
|
9
|
+
* Everything formats through here, with the locale passed in explicitly.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const FALLBACK_LOCALE = 'tr-TR';
|
|
13
|
+
|
|
14
|
+
/** Accepts "1.299,90", "1299.90", 1299.9 — returns null when unparseable */
|
|
15
|
+
export function parseAmount(value: unknown): number | null {
|
|
16
|
+
if (typeof value === 'number') {
|
|
17
|
+
return Number.isFinite(value) ? value : null;
|
|
18
|
+
}
|
|
19
|
+
if (typeof value !== 'string') return null;
|
|
20
|
+
|
|
21
|
+
const trimmed = value.trim();
|
|
22
|
+
if (!trimmed) return null;
|
|
23
|
+
|
|
24
|
+
// Strip anything that is not a digit, separator or sign, then decide
|
|
25
|
+
// which separator is the decimal one by looking at the last occurrence.
|
|
26
|
+
const cleaned = trimmed.replace(/[^\d.,-]/g, '');
|
|
27
|
+
if (!cleaned) return null;
|
|
28
|
+
|
|
29
|
+
const lastComma = cleaned.lastIndexOf(',');
|
|
30
|
+
const lastDot = cleaned.lastIndexOf('.');
|
|
31
|
+
let normalized = cleaned;
|
|
32
|
+
|
|
33
|
+
if (lastComma > lastDot) {
|
|
34
|
+
normalized = cleaned.replace(/\./g, '').replace(',', '.');
|
|
35
|
+
} else if (lastDot > lastComma) {
|
|
36
|
+
normalized = cleaned.replace(/,/g, '');
|
|
37
|
+
} else {
|
|
38
|
+
normalized = cleaned.replace(/,/g, '');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const parsed = Number(normalized);
|
|
42
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 'always' = two digits; 'auto' drops a whole amount's ",00"; 'never' = none. */
|
|
46
|
+
export type MoneyDecimals = 'always' | 'auto' | 'never';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* '' keeps the locale's own currency format. 'custom' prints the literal the
|
|
50
|
+
* brand typed (`TL`), which no locale data can produce.
|
|
51
|
+
*/
|
|
52
|
+
export type MoneyCurrencyDisplay = '' | 'symbol' | 'code' | 'custom' | 'none';
|
|
53
|
+
|
|
54
|
+
/** '' keeps the side the locale itself puts the currency on. */
|
|
55
|
+
export type MoneyCurrencyPosition = '' | 'before' | 'after';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The half of the format a brand owns, without the locale and currency the
|
|
59
|
+
* page already knows. Blocks hand their editor properties straight through,
|
|
60
|
+
* so every member is optional and every unset member reproduces the format
|
|
61
|
+
* the theme shipped with.
|
|
62
|
+
*/
|
|
63
|
+
export interface MoneyStyleOptions {
|
|
64
|
+
decimals?: MoneyDecimals;
|
|
65
|
+
currencyDisplay?: MoneyCurrencyDisplay;
|
|
66
|
+
/** Read only when `currencyDisplay` is 'custom'. */
|
|
67
|
+
currencyText?: string;
|
|
68
|
+
currencyPosition?: MoneyCurrencyPosition;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface MoneyFormatOptions extends MoneyStyleOptions {
|
|
72
|
+
locale?: string;
|
|
73
|
+
currency?: string;
|
|
74
|
+
showCurrency?: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const resolveFractionDigits = (
|
|
78
|
+
amount: number,
|
|
79
|
+
decimals: MoneyDecimals
|
|
80
|
+
): number => {
|
|
81
|
+
if (decimals === 'never') return 0;
|
|
82
|
+
if (decimals !== 'auto') return 2;
|
|
83
|
+
// Rounded before the test: 1599.004 is not an integer but prints as one, and
|
|
84
|
+
// a brand asking for "1.599" does not want that value to keep its ",00".
|
|
85
|
+
return Number.isInteger(Math.round(amount * 100) / 100) ? 0 : 2;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const formatPlain = (amount: number, locale: string, digits: number): string =>
|
|
89
|
+
new Intl.NumberFormat(locale, {
|
|
90
|
+
minimumFractionDigits: digits,
|
|
91
|
+
maximumFractionDigits: digits
|
|
92
|
+
}).format(amount);
|
|
93
|
+
|
|
94
|
+
/** The symbol a locale uses for a code, e.g. tr-TR + TRY -> ₺. */
|
|
95
|
+
const readCurrencySymbol = (locale: string, code: string): string => {
|
|
96
|
+
try {
|
|
97
|
+
const parts = new Intl.NumberFormat(locale, {
|
|
98
|
+
style: 'currency',
|
|
99
|
+
currency: code
|
|
100
|
+
}).formatToParts(1);
|
|
101
|
+
|
|
102
|
+
return parts.find((part) => part.type === 'currency')?.value || code;
|
|
103
|
+
} catch {
|
|
104
|
+
return code;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Designs are written in the brand's own money format — "1.599 TL", not the
|
|
110
|
+
* "₺1.599,00" a locale hands out — so the digit count, the currency token and
|
|
111
|
+
* the side it sits on are all options. Leaving them unset formats exactly as
|
|
112
|
+
* this file always did, which is what keeps published compositions still.
|
|
113
|
+
*/
|
|
114
|
+
export function formatMoney(
|
|
115
|
+
value: unknown,
|
|
116
|
+
{
|
|
117
|
+
locale,
|
|
118
|
+
currency,
|
|
119
|
+
showCurrency = true,
|
|
120
|
+
decimals = 'always',
|
|
121
|
+
currencyDisplay = '',
|
|
122
|
+
currencyText = '',
|
|
123
|
+
currencyPosition = ''
|
|
124
|
+
}: MoneyFormatOptions = {}
|
|
125
|
+
): string {
|
|
126
|
+
const amount = parseAmount(value);
|
|
127
|
+
if (amount === null) return '';
|
|
128
|
+
|
|
129
|
+
const resolvedLocale = locale || FALLBACK_LOCALE;
|
|
130
|
+
const code = (currency || '').trim();
|
|
131
|
+
const digits = resolveFractionDigits(amount, decimals);
|
|
132
|
+
const wantsCurrency = showCurrency && currencyDisplay !== 'none';
|
|
133
|
+
// A custom label stands on its own; every other display needs a code to
|
|
134
|
+
// resolve against, so without one the amount prints bare — as it does today.
|
|
135
|
+
const label =
|
|
136
|
+
!wantsCurrency
|
|
137
|
+
? ''
|
|
138
|
+
: currencyDisplay === 'custom'
|
|
139
|
+
? currencyText.trim()
|
|
140
|
+
: code;
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
if (!label) return formatPlain(amount, resolvedLocale, digits);
|
|
144
|
+
|
|
145
|
+
// Locale-native path: Intl owns the token, its placement and its spacing.
|
|
146
|
+
// This is where an untouched block lands, so its output cannot move.
|
|
147
|
+
if (currencyDisplay !== 'custom' && !currencyPosition) {
|
|
148
|
+
return new Intl.NumberFormat(resolvedLocale, {
|
|
149
|
+
style: 'currency',
|
|
150
|
+
currency: code,
|
|
151
|
+
currencyDisplay: currencyDisplay === 'code' ? 'code' : 'symbol',
|
|
152
|
+
minimumFractionDigits: digits,
|
|
153
|
+
maximumFractionDigits: digits
|
|
154
|
+
}).format(amount);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Intl can express neither a chosen side nor a brand's own label, so the
|
|
158
|
+
// amount is formatted alone and the token attached with a single space —
|
|
159
|
+
// one rule, both sides. The tight locale form stays available by leaving
|
|
160
|
+
// the position unset.
|
|
161
|
+
const token =
|
|
162
|
+
currencyDisplay === 'custom'
|
|
163
|
+
? label
|
|
164
|
+
: currencyDisplay === 'code'
|
|
165
|
+
? code
|
|
166
|
+
: readCurrencySymbol(resolvedLocale, code);
|
|
167
|
+
const plain = formatPlain(amount, resolvedLocale, digits);
|
|
168
|
+
|
|
169
|
+
return currencyPosition === 'before'
|
|
170
|
+
? `${token} ${plain}`
|
|
171
|
+
: `${plain} ${token}`;
|
|
172
|
+
} catch {
|
|
173
|
+
// Unknown currency code (commerce sometimes returns a symbol): fall
|
|
174
|
+
// back to a plain number plus whatever label we were handed
|
|
175
|
+
const plain = formatPlain(amount, resolvedLocale, digits);
|
|
176
|
+
return label ? `${plain} ${label}` : plain;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* A non-money number a shopper reads — today only a distance. It is not
|
|
182
|
+
* currency, so it must not be pushed through `formatMoney`, but it still needs
|
|
183
|
+
* the page's explicit locale: "2.4" and "2,4" are the same distance, and an
|
|
184
|
+
* implicit Intl locale resolves to the Node default on the server and the
|
|
185
|
+
* browser locale on the client, which hydrates as a mismatch.
|
|
186
|
+
*/
|
|
187
|
+
export function formatDecimal(
|
|
188
|
+
value: unknown,
|
|
189
|
+
locale?: string,
|
|
190
|
+
digits = 1
|
|
191
|
+
): string {
|
|
192
|
+
const amount = parseAmount(value);
|
|
193
|
+
if (amount === null) return '';
|
|
194
|
+
|
|
195
|
+
const safeDigits = Number.isFinite(digits)
|
|
196
|
+
? Math.min(6, Math.max(0, Math.round(digits)))
|
|
197
|
+
: 1;
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
return formatPlain(amount, locale || FALLBACK_LOCALE, safeDigits);
|
|
201
|
+
} catch {
|
|
202
|
+
return String(amount);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function formatDate(value: unknown, locale?: string): string {
|
|
207
|
+
if (!value) return '';
|
|
208
|
+
const parsed = new Date(value as string);
|
|
209
|
+
if (Number.isNaN(parsed.getTime())) return '';
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
return new Intl.DateTimeFormat(locale || FALLBACK_LOCALE, {
|
|
213
|
+
day: '2-digit',
|
|
214
|
+
month: '2-digit',
|
|
215
|
+
year: 'numeric'
|
|
216
|
+
}).format(parsed);
|
|
217
|
+
} catch {
|
|
218
|
+
return parsed.toISOString().slice(0, 10);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -20,6 +20,11 @@ const IconBlock = ({
|
|
|
20
20
|
currentBreakpoint = 'desktop'
|
|
21
21
|
}: BlockRendererProps) => {
|
|
22
22
|
const iconValue = getResponsiveValue(block.value, currentBreakpoint, '');
|
|
23
|
+
// An uploaded brand mark announced itself to screen readers as "Icon"
|
|
24
|
+
// because the alt the panel offers was never read.
|
|
25
|
+
const iconAlt = String(
|
|
26
|
+
getResponsiveValue(block.properties?.alt, currentBreakpoint, 'Icon')
|
|
27
|
+
);
|
|
23
28
|
const iconSize = getResponsiveValue(
|
|
24
29
|
block.styles?.size,
|
|
25
30
|
currentBreakpoint,
|
|
@@ -162,7 +167,7 @@ const IconBlock = ({
|
|
|
162
167
|
src={svgSrc}
|
|
163
168
|
width={Number(iconSize)}
|
|
164
169
|
height={Number(iconSize)}
|
|
165
|
-
alt=
|
|
170
|
+
alt={iconAlt}
|
|
166
171
|
/>
|
|
167
172
|
</div>
|
|
168
173
|
);
|
|
@@ -58,7 +58,7 @@ const ImageGalleryBlock = ({
|
|
|
58
58
|
typeof rawAlt === 'string'
|
|
59
59
|
? rawAlt
|
|
60
60
|
: typeof rawAlt === 'object' && rawAlt
|
|
61
|
-
? String(rawAlt
|
|
61
|
+
? String(getResponsiveValue(rawAlt, currentBreakpoint, ''))
|
|
62
62
|
: '';
|
|
63
63
|
|
|
64
64
|
const objectFit = getResponsiveValue(
|
|
@@ -31,7 +31,15 @@ const LinkBlock = ({
|
|
|
31
31
|
)
|
|
32
32
|
: '_self';
|
|
33
33
|
|
|
34
|
-
const
|
|
34
|
+
const authoredRel = String(
|
|
35
|
+
getResponsiveValue(block.properties?.rel, currentBreakpoint, '')
|
|
36
|
+
).trim();
|
|
37
|
+
// An external link still needs the security pair, so an authored rel is
|
|
38
|
+
// merged with it rather than replacing it.
|
|
39
|
+
const rel =
|
|
40
|
+
target === '_blank'
|
|
41
|
+
? [authoredRel, 'noopener noreferrer'].filter(Boolean).join(' ')
|
|
42
|
+
: authoredRel || undefined;
|
|
35
43
|
|
|
36
44
|
const formattedHref = useMemo(() => {
|
|
37
45
|
if (!href) {
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
getResponsiveValue,
|
|
13
13
|
resolveThemeCssVariables
|
|
14
14
|
} from '../utils';
|
|
15
|
+
import { isNarrowBreakpoint } from '../utils/breakpoints';
|
|
15
16
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
16
17
|
|
|
17
18
|
import { postToEditor } from '../editor-bridge';
|
|
@@ -118,7 +119,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
118
119
|
};
|
|
119
120
|
|
|
120
121
|
const currentSlidesToSlide =
|
|
121
|
-
currentBreakpoint
|
|
122
|
+
isNarrowBreakpoint(currentBreakpoint) ? mobileSlides : desktopSlides;
|
|
122
123
|
|
|
123
124
|
const showArrows = getResponsiveValue(
|
|
124
125
|
getConfigEntry('show-arrows'),
|
package/src/blocks/tab-block.tsx
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* A tab pane draws nothing of its own.
|
|
6
|
+
*
|
|
7
|
+
* TabsSection reaches into each tab block, pulls out its header and content
|
|
8
|
+
* children and draws them itself, because only the section knows which pane is
|
|
9
|
+
* current. This component is still registered so a tab keeps a designer
|
|
10
|
+
* wrapper (selection, rename, reorder) — it just has no markup to contribute,
|
|
11
|
+
* and the surrounding designer overlay is the only reason it mounts at all.
|
|
12
|
+
*
|
|
13
|
+
* Consequence for styling, and the reason this note exists: a tab therefore
|
|
14
|
+
* emits no `data-part` and no `data-state`, so `part/...` style keys on the
|
|
15
|
+
* tab descriptor would have nothing to land on. The current-tab hooks belong
|
|
16
|
+
* on the elements TabsSection renders (the header row, each header button, the
|
|
17
|
+
* active pane), not here.
|
|
18
|
+
*/
|
|
6
19
|
const TabBlock: React.FC<BlockRendererProps> = () => {
|
|
7
20
|
return null;
|
|
8
21
|
};
|
|
@@ -108,6 +108,12 @@ const VideoBlock = ({ block }: BlockRendererProps) => {
|
|
|
108
108
|
autoPlay={properties.autoplay}
|
|
109
109
|
loop={properties.loop}
|
|
110
110
|
muted={properties.muted}
|
|
111
|
+
poster={
|
|
112
|
+
typeof properties.poster === 'string' ? properties.poster : undefined
|
|
113
|
+
}
|
|
114
|
+
// Without it iOS opens the video full screen, which is never what an
|
|
115
|
+
// inline background clip is for.
|
|
116
|
+
playsInline={properties.playsInline !== false}
|
|
111
117
|
style={{
|
|
112
118
|
width: '100%',
|
|
113
119
|
height: 'auto'
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { act, render } from '@testing-library/react';
|
|
3
|
+
import ThemeBlock from '../../theme-block';
|
|
4
|
+
import { PartStatePreview } from '../part-state-preview';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pinning a state must stamp only the parts the pinned block owns.
|
|
8
|
+
*
|
|
9
|
+
* The editor says "show me this block's header in its open state" and the
|
|
10
|
+
* canvas writes `data-state` onto the matching elements. Part names are
|
|
11
|
+
* generic on purpose, so a plain `querySelectorAll` under the block's wrapper
|
|
12
|
+
* also finds the identically-named part of any block nested inside it. The
|
|
13
|
+
* generated stylesheet no longer reaches those elements, but the *nested*
|
|
14
|
+
* block's own state rules still do — so pinning "open" on an outer accordion
|
|
15
|
+
* could paint an inner accordion open while it is shut, and the author would
|
|
16
|
+
* be styling against a canvas that lies.
|
|
17
|
+
*
|
|
18
|
+
* A part belongs to its closest `[data-block-id]` ancestor. That is already
|
|
19
|
+
* how a click decides which part it selected (hooks/use-designer-features).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const heading = (id: string, children: unknown[] = []) => ({
|
|
23
|
+
id: `${id}-heading`,
|
|
24
|
+
type: 'group',
|
|
25
|
+
label: 'Accordion Heading',
|
|
26
|
+
properties: {},
|
|
27
|
+
styles: {},
|
|
28
|
+
order: 0,
|
|
29
|
+
hidden: false,
|
|
30
|
+
blocks: children
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const content = (id: string, children: unknown[] = []) => ({
|
|
34
|
+
id: `${id}-content`,
|
|
35
|
+
type: 'group',
|
|
36
|
+
label: 'Accordion Content',
|
|
37
|
+
properties: {},
|
|
38
|
+
styles: {},
|
|
39
|
+
order: 1,
|
|
40
|
+
hidden: false,
|
|
41
|
+
blocks: children
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const text = (id: string) => ({
|
|
45
|
+
id,
|
|
46
|
+
type: 'text',
|
|
47
|
+
label: 'Text',
|
|
48
|
+
value: '<p>copy</p>',
|
|
49
|
+
properties: {},
|
|
50
|
+
styles: {},
|
|
51
|
+
order: 0,
|
|
52
|
+
hidden: false
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const accordion = (id: string, inner?: unknown) => ({
|
|
56
|
+
id,
|
|
57
|
+
type: 'accordion',
|
|
58
|
+
label: 'Accordion',
|
|
59
|
+
value: '',
|
|
60
|
+
properties: { defaultOpen: true },
|
|
61
|
+
styles: {},
|
|
62
|
+
order: 0,
|
|
63
|
+
hidden: false,
|
|
64
|
+
blocks: [
|
|
65
|
+
heading(id, [text(`${id}-title`)]),
|
|
66
|
+
content(id, inner ? [inner] : [text(`${id}-copy`)])
|
|
67
|
+
]
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const pin = (blockId: string, partName: string, state: string | null) =>
|
|
71
|
+
act(() => {
|
|
72
|
+
window.dispatchEvent(
|
|
73
|
+
new MessageEvent('message', {
|
|
74
|
+
data: {
|
|
75
|
+
type: 'PREVIEW_PART_STATE',
|
|
76
|
+
data: { blockId, partName, state }
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** Which block owns each element currently carrying the pinned token. */
|
|
83
|
+
const stampedOwners = (root: HTMLElement, token: string): string[] => {
|
|
84
|
+
const owners = new Set<string>();
|
|
85
|
+
|
|
86
|
+
root.querySelectorAll<HTMLElement>('[data-part]').forEach((element) => {
|
|
87
|
+
const state = element.getAttribute('data-state') || '';
|
|
88
|
+
if (!state.split(/\s+/).includes(token)) return;
|
|
89
|
+
owners.add(
|
|
90
|
+
element.closest('[data-block-id]')?.getAttribute('data-block-id') || '?'
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return Array.from(owners).sort();
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const renderCanvas = () =>
|
|
98
|
+
render(
|
|
99
|
+
<>
|
|
100
|
+
<PartStatePreview isDesigner />
|
|
101
|
+
<ThemeBlock
|
|
102
|
+
block={accordion('outer', accordion('inner')) as never}
|
|
103
|
+
placeholderId="p"
|
|
104
|
+
sectionId="sec-1"
|
|
105
|
+
isDesigner
|
|
106
|
+
/>
|
|
107
|
+
</>
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
describe('pinning a part state', () => {
|
|
111
|
+
it('renders both accordions, each naming a header', () => {
|
|
112
|
+
// Guards the premise: without two headers the test below would pass for
|
|
113
|
+
// the wrong reason.
|
|
114
|
+
const { container } = renderCanvas();
|
|
115
|
+
|
|
116
|
+
expect(container.querySelectorAll('[data-part="header"]')).toHaveLength(2);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('stamps only the pinned block, not a nested block naming the same part', () => {
|
|
120
|
+
const { container } = renderCanvas();
|
|
121
|
+
|
|
122
|
+
pin('outer', 'header', 'hover');
|
|
123
|
+
|
|
124
|
+
expect(stampedOwners(container, 'hover')).toEqual(['outer']);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('still stamps the nested block when the nested block is the one pinned', () => {
|
|
128
|
+
const { container } = renderCanvas();
|
|
129
|
+
|
|
130
|
+
pin('inner', 'header', 'hover');
|
|
131
|
+
|
|
132
|
+
expect(stampedOwners(container, 'hover')).toEqual(['inner']);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('clears the token it wrote and leaves the renderer state alone', () => {
|
|
136
|
+
const { container } = renderCanvas();
|
|
137
|
+
|
|
138
|
+
pin('outer', 'header', 'hover');
|
|
139
|
+
pin('outer', 'header', null);
|
|
140
|
+
|
|
141
|
+
expect(stampedOwners(container, 'hover')).toEqual([]);
|
|
142
|
+
// `defaultOpen` is on, so the renderer's own token has to survive.
|
|
143
|
+
const header = container.querySelector(
|
|
144
|
+
'[data-block-id="outer"] [data-part="header"]'
|
|
145
|
+
) as HTMLElement;
|
|
146
|
+
expect(header.getAttribute('data-state')).toContain('open');
|
|
147
|
+
});
|
|
148
|
+
});
|