@akinon/pz-theme 2.0.36-rc.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +8 -31
  2. package/jest/__mocks__/hookform-resolvers.js +5 -0
  3. package/jest/__mocks__/next-auth-react.js +5 -0
  4. package/jest.config.js +3 -0
  5. package/package.json +4 -4
  6. package/src/blocks/accordion-block.tsx +35 -3
  7. package/src/blocks/commerce-format.ts +220 -0
  8. package/src/blocks/icon-block.tsx +6 -1
  9. package/src/blocks/image-gallery-block.tsx +1 -1
  10. package/src/blocks/link-block.tsx +9 -1
  11. package/src/blocks/slider-block.tsx +2 -1
  12. package/src/blocks/tab-block.tsx +15 -2
  13. package/src/blocks/video-block.tsx +6 -0
  14. package/src/components/__tests__/part-state-preview-scope.test.tsx +148 -0
  15. package/src/components/action-toolbar.tsx +182 -3
  16. package/src/components/part-state-preview.tsx +92 -0
  17. package/src/hooks/__tests__/use-overlay.test.tsx +503 -0
  18. package/src/hooks/use-designer-features.tsx +16 -1
  19. package/src/hooks/use-overlay.ts +538 -0
  20. package/src/hooks/use-visibility-context.ts +14 -2
  21. package/src/sections/__tests__/divider-section.test.tsx +28 -1
  22. package/src/sections/contact-form-section.tsx +4 -1
  23. package/src/sections/coupon-banner-section.tsx +2 -1
  24. package/src/sections/featured-product-spotlight-section.tsx +4 -4
  25. package/src/sections/find-in-store-section.tsx +2 -1
  26. package/src/sections/hover-showcase-section.tsx +2 -1
  27. package/src/sections/installment-options-section.tsx +2 -1
  28. package/src/sections/order-tracking-lookup-section.tsx +6 -1
  29. package/src/sections/posts-slider-section.tsx +8 -3
  30. package/src/sections/pre-order-launch-banner-section.tsx +2 -1
  31. package/src/sections/tabs-section.tsx +7 -13
  32. package/src/theme-block.tsx +81 -16
  33. package/src/theme-placeholder-client.tsx +8 -1
  34. package/src/theme-placeholder-wrapper.tsx +17 -3
  35. package/src/theme-placeholder.tsx +51 -5
  36. package/src/theme-section.tsx +20 -4
  37. package/src/utils/__tests__/breakpoint-cascade.test.ts +351 -0
  38. package/src/utils/__tests__/group-box-chrome.test.ts +83 -0
  39. package/src/utils/__tests__/iterator-bindings.test.tsx +1178 -0
  40. package/src/utils/__tests__/part-ownership-guard.test.tsx +298 -0
  41. package/src/utils/breakpoints.ts +116 -0
  42. package/src/utils/custom-css.ts +233 -0
  43. package/src/utils/index.ts +353 -42
  44. package/src/utils/iterator-utils.ts +675 -62
  45. package/src/utils/page-context-discovery.test.ts +188 -0
  46. package/src/utils/page-context-discovery.ts +48 -4
  47. package/src/utils/part-styles.ts +229 -0
  48. package/src/utils/row-visibility.ts +273 -0
  49. package/src/utils/visibility-rules.test.ts +579 -0
  50. package/src/utils/visibility-rules.ts +346 -3
package/CHANGELOG.md CHANGED
@@ -1,36 +1,13 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.36-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [0cf9ea239]
8
- - Updated dependencies [324f97d55]
9
- - Updated dependencies [51ea0688]
10
- - Updated dependencies
11
- - Updated dependencies [2fea4143]
12
- - Updated dependencies [b55acb768]
13
- - Updated dependencies [760258c1c]
14
- - Updated dependencies [143be2b9d]
15
- - Updated dependencies [7889b08fe]
16
- - Updated dependencies [9f8cd3bc5]
17
- - Updated dependencies [bfafa3f49]
18
- - Updated dependencies [57d7eb30]
19
- - Updated dependencies [d99a6a7d5]
20
- - Updated dependencies [9db81a71]
21
- - Updated dependencies [591e345e1]
22
- - Updated dependencies [4de5303c5]
23
- - Updated dependencies [95b139dc1]
24
- - Updated dependencies [1d00f2d0]
25
- - Updated dependencies [4ac7b2a1e]
26
- - Updated dependencies [e9598c71]
27
- - Updated dependencies [4998a9631]
28
- - Updated dependencies [804d2bd6]
29
- - Updated dependencies [3909d3224]
30
- - Updated dependencies [6a3d8a63]
31
- - Updated dependencies [e18836b20]
32
- - @akinon/next@2.0.36-rc.0
33
- - @akinon/theme-contract@2.0.36-rc.0
3
+ ## 2.0.36
4
+
5
+ ### Patch Changes
6
+
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
34
11
 
35
12
  ## 2.0.35
36
13
 
@@ -0,0 +1,5 @@
1
+ // Mock for @hookform/resolvers: it ships ESM that jest will not transform, and
2
+ // a render test never exercises the validation it wires up.
3
+ const yupResolver = () => async () => ({ values: {}, errors: {} });
4
+
5
+ module.exports = { yupResolver };
@@ -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-rc.0",
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-rc.0",
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-rc.0",
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-rc.0",
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 className="p-4 text-gray-400 border border-dashed border-gray-300 rounded">
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 style={{ cursor: 'pointer' }} onClick={toggleAccordion}>
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 style={{ overflow: 'hidden' }}>{renderContent()}</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="Icon"
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[currentBreakpoint] ?? rawAlt.desktop ?? '')
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 rel = target === '_blank' ? 'noopener noreferrer' : undefined;
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 === 'mobile' ? mobileSlides : desktopSlides;
122
+ isNarrowBreakpoint(currentBreakpoint) ? mobileSlides : desktopSlides;
122
123
 
123
124
  const showArrows = getResponsiveValue(
124
125
  getConfigEntry('show-arrows'),
@@ -1,8 +1,21 @@
1
1
  import React from 'react';
2
2
  import { BlockRendererProps } from './block-renderer-registry';
3
3
 
4
- // Tab blocks are rendered by TabsSection, not individually
5
- // This component should not be used directly
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
+ });