@akinon/pz-theme 2.0.78-rc.2 → 2.0.78

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 (46) hide show
  1. package/CHANGELOG.md +6 -41
  2. package/package.json +4 -4
  3. package/src/__tests__/custom-catalog-announce.test.ts +73 -0
  4. package/src/chrome/__tests__/chrome-mode.test.ts +174 -0
  5. package/src/chrome/chrome-mode.ts +61 -0
  6. package/src/custom/README-sections.md +289 -0
  7. package/src/custom/README.md +7 -0
  8. package/src/custom/__tests__/custom-section-bindings.test.tsx +327 -0
  9. package/src/custom/__tests__/define-section.test.tsx +481 -0
  10. package/src/custom/__tests__/module-boundaries.test.ts +2 -0
  11. package/src/custom/__tests__/public-api.test.tsx +123 -0
  12. package/src/custom/__tests__/region-node-wire.test.tsx +302 -0
  13. package/src/custom/__tests__/region-nodes.test.ts +161 -0
  14. package/src/custom/__tests__/theme-editable.test.tsx +22 -10
  15. package/src/custom/__tests__/theme-region.test.tsx +331 -0
  16. package/src/custom/__tests__/theme-slot.test.tsx +18 -10
  17. package/src/custom/__tests__/use-theme-setting.test.tsx +17 -9
  18. package/src/custom/block-context.ts +53 -10
  19. package/src/custom/block-host.tsx +13 -1
  20. package/src/custom/collect.ts +26 -5
  21. package/src/custom/define-section.tsx +163 -0
  22. package/src/custom/index.ts +83 -1
  23. package/src/custom/region-nodes.ts +72 -0
  24. package/src/custom/register.ts +419 -48
  25. package/src/custom/section-catalog.ts +98 -0
  26. package/src/custom/section-definitions.ts +59 -0
  27. package/src/custom/section-host.tsx +125 -0
  28. package/src/custom/section-types.ts +158 -0
  29. package/src/custom/theme-editable.tsx +3 -1
  30. package/src/custom/theme-region.tsx +213 -0
  31. package/src/custom/theme-slot.tsx +8 -7
  32. package/src/custom/types.ts +10 -1
  33. package/src/custom/use-theme-setting.ts +6 -4
  34. package/src/hooks/use-designer-features.tsx +41 -0
  35. package/src/index.ts +25 -1
  36. package/src/placeholder-registry.ts +34 -5
  37. package/src/sections/__tests__/custom-renderer-section-types.test.ts +29 -0
  38. package/src/sections/__tests__/section-part-selection.test.tsx +277 -0
  39. package/src/sections/custom-renderer-section-types.ts +19 -0
  40. package/src/sections/section-wrapper.tsx +12 -1
  41. package/src/theme-section.tsx +162 -3
  42. package/src/utils/__tests__/docked-state-styles.test.ts +249 -0
  43. package/src/utils/__tests__/section-content-width.test.ts +28 -0
  44. package/src/utils/__tests__/section-max-width.test.ts +53 -0
  45. package/src/utils/index.ts +31 -3
  46. 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 no custom block has nothing to add to a frame it
130
- // has already sent, so it stays at exactly one announce — the behaviour
131
- // every brand shipping no custom block had before any of this existed.
132
- if (!getCustomCatalog()) return;
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
  }