@akinon/pz-theme 2.0.78-rc.2 → 2.0.79-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 +10 -37
- package/package.json +4 -4
- package/src/__tests__/custom-catalog-announce.test.ts +73 -0
- package/src/chrome/__tests__/chrome-mode.test.ts +174 -0
- package/src/chrome/chrome-mode.ts +61 -0
- package/src/custom/README-sections.md +289 -0
- package/src/custom/README.md +7 -0
- package/src/custom/__tests__/custom-section-bindings.test.tsx +327 -0
- package/src/custom/__tests__/define-section.test.tsx +481 -0
- package/src/custom/__tests__/module-boundaries.test.ts +2 -0
- package/src/custom/__tests__/public-api.test.tsx +123 -0
- package/src/custom/__tests__/region-node-wire.test.tsx +302 -0
- package/src/custom/__tests__/region-nodes.test.ts +161 -0
- package/src/custom/__tests__/theme-editable.test.tsx +22 -10
- package/src/custom/__tests__/theme-region.test.tsx +331 -0
- package/src/custom/__tests__/theme-slot.test.tsx +18 -10
- package/src/custom/__tests__/use-theme-setting.test.tsx +17 -9
- package/src/custom/block-context.ts +53 -10
- package/src/custom/block-host.tsx +13 -1
- package/src/custom/collect.ts +26 -5
- package/src/custom/define-section.tsx +163 -0
- package/src/custom/index.ts +83 -1
- package/src/custom/region-nodes.ts +72 -0
- package/src/custom/register.ts +419 -48
- package/src/custom/section-catalog.ts +98 -0
- package/src/custom/section-definitions.ts +59 -0
- package/src/custom/section-host.tsx +125 -0
- package/src/custom/section-types.ts +158 -0
- package/src/custom/theme-editable.tsx +3 -1
- package/src/custom/theme-region.tsx +213 -0
- package/src/custom/theme-slot.tsx +8 -7
- package/src/custom/types.ts +10 -1
- package/src/custom/use-theme-setting.ts +6 -4
- package/src/hooks/use-designer-features.tsx +41 -0
- package/src/index.ts +25 -1
- package/src/placeholder-registry.ts +34 -5
- package/src/sections/__tests__/custom-renderer-section-types.test.ts +29 -0
- package/src/sections/__tests__/section-part-selection.test.tsx +277 -0
- package/src/sections/custom-renderer-section-types.ts +19 -0
- package/src/sections/section-wrapper.tsx +12 -1
- package/src/theme-section.tsx +162 -3
- package/src/utils/__tests__/docked-state-styles.test.ts +249 -0
- package/src/utils/__tests__/section-content-width.test.ts +28 -0
- package/src/utils/__tests__/section-max-width.test.ts +53 -0
- package/src/utils/index.ts +31 -3
- package/src/utils/part-styles.ts +17 -1
|
@@ -52,6 +52,47 @@ export function postBlockSelection({
|
|
|
52
52
|
});
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The SECTION's own part under a click, or undefined.
|
|
57
|
+
*
|
|
58
|
+
* The section twin of the block resolution inside `handleClick` below, and it
|
|
59
|
+
* lives in this file for a reason a test enforces: `checkout-hosted-parts.test.ts`
|
|
60
|
+
* refuses a `[data-part]` query anywhere in pz-theme outside the three
|
|
61
|
+
* designer-only modules, because those attributes are a STYLING contract and
|
|
62
|
+
* nothing on the live path may branch on them. This module is one of the three.
|
|
63
|
+
* Section-wrapper is not — it renders on the storefront too — so the query
|
|
64
|
+
* belongs here and the wrapper calls it from inside its own designer gate.
|
|
65
|
+
*
|
|
66
|
+
* TWO GUARDS, and the second is the one that matters.
|
|
67
|
+
*
|
|
68
|
+
* The walk stops at the section: a `data-part` above it belongs to something
|
|
69
|
+
* else entirely.
|
|
70
|
+
*
|
|
71
|
+
* And a part inside a composed CHILD is not the section's to report. The
|
|
72
|
+
* generated rule for a section part carries an ownership guard —
|
|
73
|
+
* `:not(:where(<scope> [data-block-id] *))` — so opening a panel for a part
|
|
74
|
+
* inside a block would offer controls whose CSS can never match, which is the
|
|
75
|
+
* "it saves and nothing moves" failure this whole feature is written against.
|
|
76
|
+
* The check is deliberately `parentElement?.closest(...)`, i.e. STRICTLY
|
|
77
|
+
* between: the CSS guard uses a descendant combinator, so an element carrying
|
|
78
|
+
* both `data-block-id` and `data-part` is still matched by the section's rule
|
|
79
|
+
* and is legitimately reportable.
|
|
80
|
+
*/
|
|
81
|
+
export function resolveSectionPartName(
|
|
82
|
+
target: Element | null,
|
|
83
|
+
sectionElement: Element
|
|
84
|
+
): string | undefined {
|
|
85
|
+
const partElement = target?.closest('[data-part]');
|
|
86
|
+
if (!partElement || !sectionElement.contains(partElement)) return undefined;
|
|
87
|
+
|
|
88
|
+
const enclosingBlock = partElement.parentElement?.closest('[data-block-id]');
|
|
89
|
+
if (enclosingBlock && sectionElement.contains(enclosingBlock)) {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return partElement.getAttribute('data-part') || undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
55
96
|
export function useDesignerFeatures({
|
|
56
97
|
blockId,
|
|
57
98
|
placeholderId,
|
package/src/index.ts
CHANGED
|
@@ -36,6 +36,12 @@ export {
|
|
|
36
36
|
SCROLL_STATE_THRESHOLD,
|
|
37
37
|
SCROLL_STYLE_STATE
|
|
38
38
|
} from './chrome/scroll-state';
|
|
39
|
+
export {
|
|
40
|
+
CHROME_MODE_ATTRIBUTE,
|
|
41
|
+
CHROME_MODE_DOCKED,
|
|
42
|
+
setChromeMode
|
|
43
|
+
} from './chrome/chrome-mode';
|
|
44
|
+
export type { ChromeMode } from './chrome/chrome-mode';
|
|
39
45
|
export { PreviewAuthStateReporter } from './chrome/preview-auth-state';
|
|
40
46
|
export { LiveBasketPageContext } from './chrome/live-basket-page-context';
|
|
41
47
|
export { LiveWishlistPageContext } from './chrome/live-wishlist-page-context';
|
|
@@ -171,17 +177,26 @@ export type { CheckoutPieceKey } from './chrome/checkout-pieces';
|
|
|
171
177
|
// mirror the grammar rather than re-deriving it.
|
|
172
178
|
export {
|
|
173
179
|
defineCustomBlock,
|
|
180
|
+
defineCustomSection,
|
|
174
181
|
registerCustomBlocks,
|
|
182
|
+
registerCustomSections,
|
|
175
183
|
ThemeEditable,
|
|
184
|
+
ThemeRegion,
|
|
176
185
|
ThemeSlot,
|
|
177
186
|
useThemeSetting,
|
|
178
187
|
getCustomCatalog,
|
|
188
|
+
getCustomSectionCatalog,
|
|
179
189
|
isCustomThemeType,
|
|
180
190
|
CUSTOM_TYPE_PREFIX,
|
|
181
191
|
CUSTOM_SLOT_BLOCK_TYPE,
|
|
182
192
|
SLOT_NAME_PROPERTY,
|
|
183
193
|
isSlotNode,
|
|
184
|
-
slotNodeName
|
|
194
|
+
slotNodeName,
|
|
195
|
+
REGION_NAME_PROPERTY,
|
|
196
|
+
CUSTOM_SECTION_LIMITS,
|
|
197
|
+
findRegionChild,
|
|
198
|
+
isRegionNode,
|
|
199
|
+
regionNodeName
|
|
185
200
|
} from './custom';
|
|
186
201
|
export type {
|
|
187
202
|
CustomBlockComponent,
|
|
@@ -192,6 +207,14 @@ export type {
|
|
|
192
207
|
CustomCatalog,
|
|
193
208
|
CustomPartDeclaration,
|
|
194
209
|
CustomPartsDeclaration,
|
|
210
|
+
CustomRegionDeclaration,
|
|
211
|
+
CustomRegionDescriptor,
|
|
212
|
+
CustomSectionCatalog,
|
|
213
|
+
CustomSectionComponent,
|
|
214
|
+
CustomSectionDefinition,
|
|
215
|
+
CustomSectionDescriptor,
|
|
216
|
+
CustomSectionInput,
|
|
217
|
+
CustomSectionOptions,
|
|
195
218
|
CustomSettingDeclaration,
|
|
196
219
|
CustomSettingDescriptor,
|
|
197
220
|
CustomSettingOption,
|
|
@@ -200,6 +223,7 @@ export type {
|
|
|
200
223
|
EditableKind,
|
|
201
224
|
PropertyType,
|
|
202
225
|
ThemeEditableProps,
|
|
226
|
+
ThemeRegionProps,
|
|
203
227
|
ThemeSettingChoice,
|
|
204
228
|
ThemeSettingOptions,
|
|
205
229
|
ThemeSlotProps
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { getCustomCatalog } from './custom/catalog';
|
|
1
|
+
import { getCatalogVendor, getCustomCatalog } from './custom/catalog';
|
|
2
|
+
import { getCustomSectionCatalog } from './custom/section-catalog';
|
|
3
|
+
import type { CustomSectionCatalog } from './custom/section-types';
|
|
2
4
|
import type { CustomCatalog } from './custom/types';
|
|
3
5
|
import { isTrustedEditorEvent, postToEditor } from './editor-bridge';
|
|
4
6
|
|
|
@@ -33,6 +35,23 @@ interface PlaceholdersAvailablePayload {
|
|
|
33
35
|
* nothing can render.
|
|
34
36
|
*/
|
|
35
37
|
customBlocks?: CustomCatalog;
|
|
38
|
+
/**
|
|
39
|
+
* The section catalog, on the same terms as `customBlocks` and as a SIBLING
|
|
40
|
+
* of it rather than a key inside it.
|
|
41
|
+
*
|
|
42
|
+
* Nesting would have been cheaper here and much more expensive everywhere
|
|
43
|
+
* else: the editor validates `customBlocks` element by element and drops the
|
|
44
|
+
* WHOLE value on an unexpected shape, so an editor build older than this
|
|
45
|
+
* change would answer a catalog carrying sections by emptying the brand's
|
|
46
|
+
* block palette. A sibling key is simply ignored by an older receiver, and a
|
|
47
|
+
* new field on an existing message needs no `@akinon/theme-contract` publish
|
|
48
|
+
* — `parseThemeMessage` validates the envelope, not the payload.
|
|
49
|
+
*
|
|
50
|
+
* A sibling also means a separate byte budget on both sides, which is what
|
|
51
|
+
* keeps one oversized section descriptor from taking the block palette down
|
|
52
|
+
* with it.
|
|
53
|
+
*/
|
|
54
|
+
customSections?: CustomSectionCatalog;
|
|
36
55
|
}
|
|
37
56
|
|
|
38
57
|
const isFramed = (): boolean =>
|
|
@@ -81,6 +100,9 @@ const flush = () => {
|
|
|
81
100
|
// must be indistinguishable from one whose catalog simply is not this
|
|
82
101
|
// frame's business.
|
|
83
102
|
if (customBlocks) data.customBlocks = customBlocks;
|
|
103
|
+
|
|
104
|
+
const customSections = getCustomSectionCatalog(getCatalogVendor());
|
|
105
|
+
if (customSections) data.customSections = customSections;
|
|
84
106
|
}
|
|
85
107
|
|
|
86
108
|
postToEditor({
|
|
@@ -126,10 +148,17 @@ const handleEditorMessage = (event: MessageEvent) => {
|
|
|
126
148
|
if (designerReannounceSpent) return;
|
|
127
149
|
designerReannounceSpent = true;
|
|
128
150
|
|
|
129
|
-
// A build that registers
|
|
130
|
-
// has already sent, so it stays at exactly one announce — the
|
|
131
|
-
// every brand shipping
|
|
132
|
-
|
|
151
|
+
// A build that registers nothing developer-authored has nothing to add to a
|
|
152
|
+
// frame it has already sent, so it stays at exactly one announce — the
|
|
153
|
+
// behaviour every brand shipping neither had before any of this existed.
|
|
154
|
+
//
|
|
155
|
+
// BOTH catalogs, not just the block one: a brand that ships only custom
|
|
156
|
+
// sections would otherwise never get its second frame, and its section band
|
|
157
|
+
// would be empty on exactly the loads where the cookie handshake lost the
|
|
158
|
+
// 100ms debounce — intermittently, and invisibly.
|
|
159
|
+
if (!getCustomCatalog() && !getCustomSectionCatalog(getCatalogVendor())) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
133
162
|
|
|
134
163
|
// Re-arm rather than flush: the flag itself is set by the placeholder
|
|
135
164
|
// client's own listener, and listener order across modules is not ours to
|
|
@@ -68,3 +68,32 @@ describe('hasCustomSectionRenderer', () => {
|
|
|
68
68
|
).toBe(true);
|
|
69
69
|
});
|
|
70
70
|
});
|
|
71
|
+
|
|
72
|
+
describe('hasCustomSectionRenderer — a developer-authored section', () => {
|
|
73
|
+
it('is true for a brand section type, decided by grammar alone', () => {
|
|
74
|
+
// No registry is consulted, and none can be: `registerCustomSections` is
|
|
75
|
+
// client-only, so the SERVER copy of any registry is permanently empty
|
|
76
|
+
// while this module is read by the server-side CSS generator. The same
|
|
77
|
+
// generator then runs again on the client, which would emit a DIFFERENT
|
|
78
|
+
// stylesheet for the same page — with the client `<style>` winning by
|
|
79
|
+
// source order. The grammar is identical in both.
|
|
80
|
+
expect(hasCustomSectionRenderer(section({ type: 'custom:acme:hero' }))).toBe(
|
|
81
|
+
true
|
|
82
|
+
);
|
|
83
|
+
expect(
|
|
84
|
+
hasCustomSectionRenderer(section({ type: 'custom:zsazsazsu:header' }))
|
|
85
|
+
).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('is false for every string that merely looks like one', () => {
|
|
89
|
+
// A half-typed or mis-cased type must fall back to the generic loop rather
|
|
90
|
+
// than being handed a box nothing draws: `ownsContentBox` decides where the
|
|
91
|
+
// child-layout keys land, so a wrong answer here silently re-lays-out the
|
|
92
|
+
// section.
|
|
93
|
+
['custom:', 'custom:acme', 'custom:acme:', 'Custom:Acme:Hero'].forEach(
|
|
94
|
+
(type) => {
|
|
95
|
+
expect(hasCustomSectionRenderer(section({ type }))).toBe(false);
|
|
96
|
+
}
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { fireEvent, render } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
import SectionWrapper from '../section-wrapper';
|
|
5
|
+
import ThemeSection from '../../theme-section';
|
|
6
|
+
import type { Section } from '../../theme-section';
|
|
7
|
+
import { ThemeSettingsProvider } from '../../theme-settings-context';
|
|
8
|
+
import { postToEditor } from '../../editor-bridge';
|
|
9
|
+
|
|
10
|
+
// `isTrustedEditorEvent` rides along because the wrapper's SCROLL_TO_SECTION
|
|
11
|
+
// listener imports it from the same module this mock replaces.
|
|
12
|
+
jest.mock('../../editor-bridge', () => ({
|
|
13
|
+
postToEditor: jest.fn(),
|
|
14
|
+
isTrustedEditorEvent: jest.fn(() => false)
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
jest.mock('../../components/action-toolbar', () => ({
|
|
18
|
+
__esModule: true,
|
|
19
|
+
default: () => <div data-testid="action-toolbar" />
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
// The recursive block renderer, stubbed so a composed child is one assertable
|
|
23
|
+
// node instead of the whole block registry.
|
|
24
|
+
jest.mock('../../theme-block', () => ({
|
|
25
|
+
__esModule: true,
|
|
26
|
+
default: ({ block }: { block: { id: string } }) => (
|
|
27
|
+
<div data-block-id={block.id} data-testid="composed-block">
|
|
28
|
+
<span data-part="title" data-testid="block-part">
|
|
29
|
+
block title
|
|
30
|
+
</span>
|
|
31
|
+
</div>
|
|
32
|
+
)
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
const postToEditorMock = postToEditor as jest.Mock;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A CLICK ON A SECTION'S OWN BAND MUST OPEN THAT BAND'S PANEL.
|
|
39
|
+
*
|
|
40
|
+
* The editor has always been able to receive it — `theme-iframe-service`
|
|
41
|
+
* destructures `partName` off SELECT_SECTION and the properties panel opens
|
|
42
|
+
* the part panel — but no pz-theme sender ever put the field on the message.
|
|
43
|
+
* A developer-authored header therefore advertised six bands (`bar`,
|
|
44
|
+
* `topRow`, `logoArea`, `leftActions`, `actions`, `navRow`) and a click on any
|
|
45
|
+
* of them selected the section ROOT, leaving the merchant to guess which of
|
|
46
|
+
* the six names was the band under the cursor. The identical click inside a
|
|
47
|
+
* BLOCK already opened its part panel.
|
|
48
|
+
*
|
|
49
|
+
* Three properties are locked here, each a real failure mode:
|
|
50
|
+
*
|
|
51
|
+
* - BOUNDED WALK: resolution stops at the section. A `[data-part]` above it
|
|
52
|
+
* belongs to something else.
|
|
53
|
+
* - BLOCK PARTS ARE NOT SECTION PARTS: the generated section part rule carries
|
|
54
|
+
* `:not(:where(<section> [data-block-id] *))` (buildOwnershipGuard in
|
|
55
|
+
* utils/part-styles.ts), so naming a part from inside a composed child would
|
|
56
|
+
* open a panel whose every control writes CSS that can never match.
|
|
57
|
+
* - OMITTED, NOT EMPTY: with no part, an ordinary section click posts a
|
|
58
|
+
* payload byte-identical to the one sent before this existed.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
const makeSection = (overrides: Partial<Section> = {}): Section => ({
|
|
62
|
+
id: 'sec-1',
|
|
63
|
+
type: 'generic-section',
|
|
64
|
+
name: 'name',
|
|
65
|
+
label: 'My Section',
|
|
66
|
+
properties: {},
|
|
67
|
+
styles: {},
|
|
68
|
+
blocks: [],
|
|
69
|
+
order: 0,
|
|
70
|
+
hidden: false,
|
|
71
|
+
...overrides
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const lastSelectSectionPayload = () => {
|
|
75
|
+
const call = [...postToEditorMock.mock.calls]
|
|
76
|
+
.reverse()
|
|
77
|
+
.find(([message]) => message?.type === 'SELECT_SECTION');
|
|
78
|
+
|
|
79
|
+
return call?.[0]?.data as Record<string, unknown> | undefined;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
postToEditorMock.mockClear();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('SectionWrapper — part resolution on a section click', () => {
|
|
87
|
+
const renderWrapper = (children: React.ReactNode) =>
|
|
88
|
+
render(
|
|
89
|
+
<SectionWrapper
|
|
90
|
+
section={makeSection()}
|
|
91
|
+
placeholderId="header"
|
|
92
|
+
isDesigner
|
|
93
|
+
onSelect={jest.fn()}
|
|
94
|
+
>
|
|
95
|
+
{children}
|
|
96
|
+
</SectionWrapper>
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
it('reports the part-stamped band the click landed on', () => {
|
|
100
|
+
const { getByTestId } = renderWrapper(
|
|
101
|
+
<header data-part="bar">
|
|
102
|
+
<div data-part="topRow" data-testid="top-row">
|
|
103
|
+
<span>ZSA</span>
|
|
104
|
+
</div>
|
|
105
|
+
</header>
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
fireEvent.click(getByTestId('top-row'));
|
|
109
|
+
|
|
110
|
+
expect(lastSelectSectionPayload()).toEqual({
|
|
111
|
+
placeholderId: 'header',
|
|
112
|
+
sectionId: 'sec-1',
|
|
113
|
+
partName: 'topRow'
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('reports the NEAREST band, not the outermost one', () => {
|
|
118
|
+
const { getByTestId } = renderWrapper(
|
|
119
|
+
<header data-part="bar">
|
|
120
|
+
<div data-part="topRow">
|
|
121
|
+
<div data-part="logoArea">
|
|
122
|
+
<span data-testid="logo-text">ZSA</span>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</header>
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
fireEvent.click(getByTestId('logo-text'));
|
|
129
|
+
|
|
130
|
+
expect(lastSelectSectionPayload()?.partName).toBe('logoArea');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('omits the partName KEY entirely on a bare section click', () => {
|
|
134
|
+
const { container } = renderWrapper(<span>plain content</span>);
|
|
135
|
+
|
|
136
|
+
fireEvent.click(container.querySelector('[data-section-id]') as Element);
|
|
137
|
+
|
|
138
|
+
const payload = lastSelectSectionPayload();
|
|
139
|
+
// Not `toBeUndefined()`: the key must be ABSENT, so the frame stays
|
|
140
|
+
// byte-identical to what an ordinary section click always posted.
|
|
141
|
+
expect(payload).toEqual({ placeholderId: 'header', sectionId: 'sec-1' });
|
|
142
|
+
expect(payload && 'partName' in payload).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('omits partName for a part inside a nested block', () => {
|
|
146
|
+
const { getByTestId } = renderWrapper(
|
|
147
|
+
<div data-part="actions">
|
|
148
|
+
<div data-block-id="blk-1">
|
|
149
|
+
<span data-part="title" data-testid="nested-part">
|
|
150
|
+
Basket
|
|
151
|
+
</span>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
fireEvent.click(getByTestId('nested-part'));
|
|
157
|
+
|
|
158
|
+
const payload = lastSelectSectionPayload();
|
|
159
|
+
expect(payload && 'partName' in payload).toBe(false);
|
|
160
|
+
// And it must not fall back to the enclosing band either: the click was
|
|
161
|
+
// not on `actions`, so answering with it would style the wrong element.
|
|
162
|
+
expect(payload).toEqual({ placeholderId: 'header', sectionId: 'sec-1' });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('omits partName for unstamped markup inside a nested block', () => {
|
|
166
|
+
// The case the guard exists for: a block renders markup its own click
|
|
167
|
+
// handler does not cover, so the click reaches the section handler.
|
|
168
|
+
const { getByTestId } = renderWrapper(
|
|
169
|
+
<div data-part="actions">
|
|
170
|
+
<div data-block-id="blk-1">
|
|
171
|
+
<span data-part="row">
|
|
172
|
+
<em data-testid="deep-node">x</em>
|
|
173
|
+
</span>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
fireEvent.click(getByTestId('deep-node'));
|
|
179
|
+
|
|
180
|
+
expect(lastSelectSectionPayload()).toEqual({
|
|
181
|
+
placeholderId: 'header',
|
|
182
|
+
sectionId: 'sec-1'
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('still reports a part stamped on a block ROOT, matching the CSS guard', () => {
|
|
187
|
+
// `buildOwnershipGuard` excludes `<section> [data-block-id] *` —
|
|
188
|
+
// DESCENDANTS of a block, not the block element itself. An element
|
|
189
|
+
// carrying both attributes is still matched by the section part rule, so
|
|
190
|
+
// reporting it opens a panel whose writes do apply.
|
|
191
|
+
const { getByTestId } = renderWrapper(
|
|
192
|
+
<div data-block-id="blk-1" data-part="bar" data-testid="block-root" />
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
fireEvent.click(getByTestId('block-root'));
|
|
196
|
+
|
|
197
|
+
expect(lastSelectSectionPayload()?.partName).toBe('bar');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('omits partName for an empty data-part value', () => {
|
|
201
|
+
const { getByTestId } = renderWrapper(
|
|
202
|
+
<div data-part="" data-testid="blank-part" />
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
fireEvent.click(getByTestId('blank-part'));
|
|
206
|
+
|
|
207
|
+
expect(lastSelectSectionPayload()).toEqual({
|
|
208
|
+
placeholderId: 'header',
|
|
209
|
+
sectionId: 'sec-1'
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('posts nothing at all when not in designer mode', () => {
|
|
214
|
+
const { getByTestId } = render(
|
|
215
|
+
<SectionWrapper section={makeSection()} placeholderId="header">
|
|
216
|
+
<div data-part="topRow" data-testid="top-row" />
|
|
217
|
+
</SectionWrapper>
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
fireEvent.click(getByTestId('top-row'));
|
|
221
|
+
|
|
222
|
+
expect(postToEditorMock).not.toHaveBeenCalled();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe('ThemeSection generic path — same resolution on its own section', () => {
|
|
227
|
+
const renderGeneric = (section: Section) =>
|
|
228
|
+
render(
|
|
229
|
+
<ThemeSettingsProvider value={null}>
|
|
230
|
+
<ThemeSection
|
|
231
|
+
section={section}
|
|
232
|
+
placeholderId="home"
|
|
233
|
+
currentBreakpoint="desktop"
|
|
234
|
+
isDesigner
|
|
235
|
+
onSelect={jest.fn()}
|
|
236
|
+
/>
|
|
237
|
+
</ThemeSettingsProvider>
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
it('omits the partName key on a bare section click', () => {
|
|
241
|
+
const { container } = renderGeneric(makeSection({ id: 'gen-1' }));
|
|
242
|
+
|
|
243
|
+
fireEvent.click(
|
|
244
|
+
container.querySelector('[data-section-id="gen-1"]') as Element
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
expect(lastSelectSectionPayload()).toEqual({
|
|
248
|
+
placeholderId: 'home',
|
|
249
|
+
sectionId: 'gen-1'
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('omits partName for a part inside one of its composed blocks', () => {
|
|
254
|
+
const { getByTestId } = renderGeneric(
|
|
255
|
+
makeSection({
|
|
256
|
+
id: 'gen-2',
|
|
257
|
+
blocks: [
|
|
258
|
+
{
|
|
259
|
+
id: 'blk-1',
|
|
260
|
+
type: 'text',
|
|
261
|
+
label: 'Text',
|
|
262
|
+
value: '',
|
|
263
|
+
properties: {},
|
|
264
|
+
styles: {},
|
|
265
|
+
order: 0,
|
|
266
|
+
hidden: false
|
|
267
|
+
}
|
|
268
|
+
] as unknown as Section['blocks']
|
|
269
|
+
})
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
fireEvent.click(getByTestId('block-part'));
|
|
273
|
+
|
|
274
|
+
const payload = lastSelectSectionPayload();
|
|
275
|
+
expect(payload && 'partName' in payload).toBe(false);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isCustomThemeType } from '../custom/types';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* The section types that render through their own component instead of the
|
|
3
5
|
* generic block loop, as plain data.
|
|
@@ -47,5 +49,22 @@ export const hasCustomSectionRenderer = (section: {
|
|
|
47
49
|
}): boolean => {
|
|
48
50
|
if (section.blocks?.some((block) => block.type === 'tab')) return true;
|
|
49
51
|
|
|
52
|
+
// A developer-authored section draws its own box, exactly as a registered
|
|
53
|
+
// renderer does, so its child-layout keys belong on the band rather than
|
|
54
|
+
// being moved onto the content wrapper.
|
|
55
|
+
//
|
|
56
|
+
// ANSWERED BY GRAMMAR, NEVER BY LOOKUP. Registration is client-only and this
|
|
57
|
+
// module is read by the SERVER-side CSS generator, so the server copy of any
|
|
58
|
+
// registry is permanently empty — and the same generator runs again on the
|
|
59
|
+
// client, which would then emit a DIFFERENT stylesheet for the same page,
|
|
60
|
+
// with the client's `<style>` winning by source order. The grammar is
|
|
61
|
+
// identical in both.
|
|
62
|
+
//
|
|
63
|
+
// Imported from `../custom/types`, NEVER from `../custom`: the barrel
|
|
64
|
+
// re-exports `register.ts`, which carries 'use client' and drags ~99 block
|
|
65
|
+
// modules into whatever graph imports it. `types.ts` is directive-free and
|
|
66
|
+
// its only import is a type.
|
|
67
|
+
if (isCustomThemeType(section.type)) return true;
|
|
68
|
+
|
|
50
69
|
return CUSTOM_RENDERER_SECTION_TYPES.includes(section.type ?? '');
|
|
51
70
|
};
|
|
@@ -8,6 +8,9 @@ import { Section } from '../theme-section';
|
|
|
8
8
|
import { SECTION_CONTENT_ATTRIBUTE } from '../utils/section-content-width';
|
|
9
9
|
|
|
10
10
|
import { isTrustedEditorEvent, postToEditor } from '../editor-bridge';
|
|
11
|
+
import { resolveSectionPartName } from '../hooks/use-designer-features';
|
|
12
|
+
|
|
13
|
+
|
|
11
14
|
interface SectionWrapperProps {
|
|
12
15
|
section: Section;
|
|
13
16
|
placeholderId: string;
|
|
@@ -90,12 +93,20 @@ export default function SectionWrapper({
|
|
|
90
93
|
e.stopPropagation();
|
|
91
94
|
onSelect(section.id);
|
|
92
95
|
|
|
96
|
+
const partName = resolveSectionPartName(
|
|
97
|
+
e.target as Element | null,
|
|
98
|
+
e.currentTarget as Element
|
|
99
|
+
);
|
|
100
|
+
|
|
93
101
|
if (window.parent) {
|
|
94
102
|
postToEditor({
|
|
95
103
|
type: 'SELECT_SECTION',
|
|
96
104
|
data: {
|
|
97
105
|
placeholderId,
|
|
98
|
-
sectionId: section.id
|
|
106
|
+
sectionId: section.id,
|
|
107
|
+
// Omitted rather than sent empty, so an ordinary section click
|
|
108
|
+
// posts exactly the payload it always has.
|
|
109
|
+
...(partName ? { partName } : {})
|
|
99
110
|
}
|
|
100
111
|
});
|
|
101
112
|
}
|