@akinon/pz-theme 2.0.78-rc.1 → 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 +12 -39
  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
@@ -0,0 +1,163 @@
1
+ import type React from 'react';
2
+ import type { CustomPartsDeclaration } from './parts';
3
+ import type { CustomSettingDeclaration, CustomSlotDeclaration } from './define';
4
+
5
+ /**
6
+ * A developer section component takes NO props, for the same reason a block
7
+ * does: everything it needs — the section node, the breakpoint, the designer
8
+ * flag — arrives through `CustomBlockContext`, so a `<ThemeRegion>` five levels
9
+ * down resolves without the developer threading anything through their tree.
10
+ */
11
+ export type CustomSectionComponent = React.ComponentType<Record<string, never>>;
12
+
13
+ /**
14
+ * One child the developer names and PLACES in JSX with `<ThemeRegion name=…>`.
15
+ *
16
+ * `block` is the type it is born as. The developer writes no descriptor for it:
17
+ * naming `search-box` is what buys the whole search-box panel, its shared
18
+ * controls and its part groups. That is the difference between this design and
19
+ * the retired one, where every child's controls had to be described by hand and
20
+ * the descriptors grew to 16,058 lines.
21
+ *
22
+ * `properties` and `styles` are the seed — what the region looks like the
23
+ * moment the section is added, before anyone has touched it. They are applied
24
+ * ONCE, at insert. Nothing re-applies them on a later load, so a merchant's
25
+ * edit is never quietly reverted.
26
+ */
27
+ export interface CustomRegionDeclaration {
28
+ name: string;
29
+ /** The layer-tree row name. Defaults to the region name. */
30
+ label?: string;
31
+ block: string;
32
+ properties?: Record<string, unknown>;
33
+ styles?: Record<string, unknown>;
34
+ /**
35
+ * The block's own `value`, for the block types that keep their content there
36
+ * rather than in `properties` — an `image` reads `block.value` for its src, a
37
+ * `text` reads it for its text. Without this a seeded logo would come up
38
+ * blank and the merchant would have to find and re-upload an asset the brand
39
+ * already ships.
40
+ *
41
+ * Seeded ONCE, at insert, like `properties` and `styles`.
42
+ */
43
+ value?: unknown;
44
+ }
45
+
46
+ export interface CustomSectionOptions {
47
+ /** Shown in the editor's Add Section band. Defaults to the registration name. */
48
+ label?: string;
49
+ icon?: string;
50
+ /** The band this section is filed under. Defaults to the vendor's own band. */
51
+ group?: string;
52
+ settings?: CustomSettingDeclaration[];
53
+ /**
54
+ * The children the developer placed. Order here is the order they are seeded
55
+ * into `section.blocks`, which is the order the layer tree shows — but NOT
56
+ * what decides where they appear on screen. The JSX owns that, and no
57
+ * reordering in the tree can change it.
58
+ */
59
+ regions?: CustomRegionDeclaration[];
60
+ /** Merchant-fillable openings, identical in every way to a block's slots. */
61
+ slots?: CustomSlotDeclaration[];
62
+ /**
63
+ * Elements the developer stamped with `partAttrs()` that the merchant may
64
+ * restyle. Section parts already emit CSS — `generatePartCSS` runs against
65
+ * the section selector — and this is the declaration that gives them a panel.
66
+ */
67
+ parts?: CustomPartsDeclaration;
68
+ /** The section root's styles at insert. Same one-shot seed rule as a region's. */
69
+ styles?: Record<string, unknown>;
70
+ /**
71
+ * Placeholder slugs this section is offered on — `['header']` for a header.
72
+ * Absent means anywhere.
73
+ */
74
+ placeholders?: string[];
75
+ /**
76
+ * Keeps `rotate`, `scale`, `translate`, `filter` and `backdrop-filter` off the
77
+ * section root's panel.
78
+ *
79
+ * Set it on any section whose design contains a `position: fixed` or
80
+ * `sticky` descendant. Each of those five properties creates a containing
81
+ * block for a fixed descendant, so one of them applied to the root collapses
82
+ * the fixed child into the root's own box — a header measured at 1247px wide
83
+ * became 600px. The merchant has no way to know that and nothing reports it.
84
+ */
85
+ lockRootTransforms?: boolean;
86
+ }
87
+
88
+ /**
89
+ * Tag value, not a `Symbol` — see `CUSTOM_BLOCK_MARKER`. A brand can resolve two
90
+ * physical copies of pz-theme at once, and a `Symbol()` per copy would make one
91
+ * copy read the other's definition as a bare component, silently dropping every
92
+ * option the developer declared.
93
+ */
94
+ export const CUSTOM_SECTION_MARKER = 'pz-theme.custom-section';
95
+
96
+ export interface CustomSectionDefinition {
97
+ readonly $$pzCustomSection: typeof CUSTOM_SECTION_MARKER;
98
+ component: CustomSectionComponent;
99
+ options: CustomSectionOptions;
100
+ }
101
+
102
+ export type CustomSectionInput =
103
+ | CustomSectionDefinition
104
+ | CustomSectionComponent;
105
+
106
+ // React.memo() and forwardRef() return objects rather than functions, and a
107
+ // brand wrapping its section in either is ordinary React.
108
+ const isRenderable = (value: unknown): value is CustomSectionComponent =>
109
+ typeof value === 'function' ||
110
+ (typeof value === 'object' && value !== null && '$$typeof' in value);
111
+
112
+ export const isCustomSectionDefinition = (
113
+ value: unknown
114
+ ): value is CustomSectionDefinition =>
115
+ typeof value === 'object' &&
116
+ value !== null &&
117
+ (value as { $$pzCustomSection?: unknown }).$$pzCustomSection ===
118
+ CUSTOM_SECTION_MARKER;
119
+
120
+ /**
121
+ * Wraps a section component with the options the editor needs.
122
+ *
123
+ * Like `defineCustomBlock` it deliberately does NOT read `Component.name`:
124
+ * production builds minify it, so a section authored as `ZsaHeader` would
125
+ * register as `t` on the storefront and as `ZsaHeader` in dev, and one
126
+ * composition would render in one build and be an unknown type in the other.
127
+ * The name lives at registration, keyed by the record.
128
+ *
129
+ * export default defineCustomSection(ZsaHeader, { label: 'ZSA ZSA ZSU Header' });
130
+ */
131
+ export const defineCustomSection = (
132
+ component: CustomSectionComponent,
133
+ options: CustomSectionOptions = {}
134
+ ): CustomSectionDefinition => {
135
+ if (!isRenderable(component)) {
136
+ throw new Error(
137
+ `[pz-theme] defineCustomSection() expects a React component, received ${typeof component}. ` +
138
+ `Pass the component itself, not the result of calling it.`
139
+ );
140
+ }
141
+
142
+ return Object.freeze({
143
+ $$pzCustomSection: CUSTOM_SECTION_MARKER,
144
+ component,
145
+ options
146
+ });
147
+ };
148
+
149
+ /** Accepts the wrapper or a bare component — see `normalizeCustomBlock`. */
150
+ export const normalizeCustomSection = (
151
+ input: CustomSectionInput,
152
+ name: string
153
+ ): CustomSectionDefinition => {
154
+ if (isCustomSectionDefinition(input)) return input;
155
+
156
+ if (isRenderable(input)) return defineCustomSection(input);
157
+
158
+ throw new Error(
159
+ `[pz-theme] Custom section "${name}" is neither a React component nor a defineCustomSection() result ` +
160
+ `(received ${input === null ? 'null' : typeof input}). ` +
161
+ `Check the barrel entry — a missing default export lands here as undefined.`
162
+ );
163
+ };
@@ -51,7 +51,70 @@ export type {
51
51
 
52
52
  export type { CustomPartDeclaration, CustomPartsDeclaration } from './parts';
53
53
 
54
- export { registerCustomBlocks } from './register';
54
+ export { registerCustomBlocks, registerCustomSections } from './register';
55
+
56
+ /**
57
+ * The SECTION authoring API.
58
+ *
59
+ * A section is what a developer reaches for when the whole area is theirs — a
60
+ * header, a product detail page — rather than one element inside an area the
61
+ * merchant composed. Two things follow from that and neither is available to a
62
+ * block:
63
+ *
64
+ * - The section root gets the section root panel: the geometry family and
65
+ * Content Width, which the editor adds only for a section root or a part.
66
+ * - There is no merchant-created wrapper around it. A custom block has to live
67
+ * inside a section somebody added, and that wrapper can carry `transform` or
68
+ * `filter` — either of which traps a `position: fixed` descendant, measured
69
+ * as a 1247px header collapsing to 600px. When the header IS the section, the
70
+ * wrapper stops existing.
71
+ *
72
+ * The shape a brand writes:
73
+ *
74
+ * // views/header/zzz-header.tsx
75
+ * import { defineCustomSection, ThemeRegion, ThemeSlot, partAttrs }
76
+ * from '@akinon/pz-theme/src/custom';
77
+ *
78
+ * function ZsaHeader() {
79
+ * return (
80
+ * <header {...partAttrs('bar')}>
81
+ * <ThemeRegion name="logo" />
82
+ * <ThemeRegion name="basket" />
83
+ * <ThemeSlot name="extras" label="Extra header actions" />
84
+ * </header>
85
+ * );
86
+ * }
87
+ *
88
+ * export default defineCustomSection(ZsaHeader, {
89
+ * label: 'ZSA ZSA ZSU Header',
90
+ * regions: [
91
+ * { name: 'logo', label: 'Logo', block: 'image' },
92
+ * { name: 'basket', label: 'Basket', block: 'mini-basket' }
93
+ * ],
94
+ * slots: [{ name: 'extras', label: 'Extra header actions' }],
95
+ * lockRootTransforms: true
96
+ * });
97
+ *
98
+ * // views/header/index.ts — imported from the CLIENT ROOT only
99
+ * registerCustomSections('zsazsazsu', { header: ZsaHeader });
100
+ *
101
+ * A REGION AND A SLOT ARE DIFFERENT PROMISES. A region is a child the developer
102
+ * named, chose the type of and PLACED; the merchant styles it, configures it,
103
+ * hides it or deletes it, but cannot reorder it or insert beside it, because
104
+ * the JSX owns the layout. A slot is an opening the merchant fills with
105
+ * whatever they like, in whatever order. Say which one you mean in the label.
106
+ */
107
+ export { defineCustomSection } from './define-section';
108
+ export type {
109
+ CustomRegionDeclaration,
110
+ CustomSectionComponent,
111
+ CustomSectionDefinition,
112
+ CustomSectionInput,
113
+ CustomSectionOptions
114
+ } from './define-section';
115
+
116
+ export { ThemeRegion } from './theme-region';
117
+ export type { ThemeRegionProps } from './theme-region';
55
118
 
56
119
  export { ThemeEditable } from './theme-editable';
57
120
  export type { ThemeEditableProps } from './theme-editable';
@@ -78,6 +141,13 @@ export type {
78
141
  PropertyType
79
142
  } from './types';
80
143
 
144
+ export { getCustomSectionCatalog } from './section-catalog';
145
+ export type {
146
+ CustomRegionDescriptor,
147
+ CustomSectionCatalog,
148
+ CustomSectionDescriptor
149
+ } from './section-types';
150
+
81
151
  // The predicate, not the pattern. Every consumer that has to treat a
82
152
  // developer-authored type differently tests this one function, so the grammar
83
153
  // stays in types.ts and cannot be re-derived slightly wrong somewhere else.
@@ -92,6 +162,18 @@ export { CUSTOM_TYPE_PREFIX, isCustomThemeType } from './types';
92
162
  export { CUSTOM_SLOT_BLOCK_TYPE, SLOT_NAME_PROPERTY } from './types';
93
163
  export { isSlotNode, slotNodeName } from './slot-nodes';
94
164
 
165
+ /**
166
+ * The region grammar, exported for the same reason the slot grammar is: the
167
+ * editor holds a mirror of it, and a mirror can be diffed against one exported
168
+ * constant instead of a literal re-typed slightly wrong.
169
+ *
170
+ * `REGION_NAME_PROPERTY` is a reserved key on an ORDINARY block — a region is
171
+ * not a wrapper node, which is the whole point: the merchant gets that block
172
+ * type's own panel.
173
+ */
174
+ export { REGION_NAME_PROPERTY, CUSTOM_SECTION_LIMITS } from './section-types';
175
+ export { findRegionChild, isRegionNode, regionNodeName } from './region-nodes';
176
+
95
177
  /**
96
178
  * Re-exported so a custom block is written against ONE module.
97
179
  *
@@ -0,0 +1,72 @@
1
+ import type { Block } from '../theme-block';
2
+ import { REGION_NAME_PROPERTY } from './section-types';
3
+
4
+ /**
5
+ * Reading regions back out of a composition.
6
+ *
7
+ * A declared region is seeded as one ORDINARY block in `section.blocks`,
8
+ * carrying `properties.regionName`. `<ThemeRegion name="search" />` finds that
9
+ * block and hands it to the same child pipeline every container uses, so the
10
+ * merchant styles and configures it through the panel that block type already
11
+ * has.
12
+ *
13
+ * THE ASYMMETRY WITH SLOTS IS DELIBERATE AND LOAD-BEARING. `resolveSlotChildren`
14
+ * has a legacy branch that treats a block's direct children as the content of
15
+ * its unnamed slot. Nothing like it can exist here: a section's direct children
16
+ * are the merchant's ordinary composed blocks, so a bare region falling back to
17
+ * them would swallow the entire section. A region resolves by NAME or not at
18
+ * all, and there is no unnamed region.
19
+ *
20
+ * Directive-free on purpose — `register.ts`, `theme-region.tsx` and the editor
21
+ * mirror all need the same answers, and only the second is a client module.
22
+ */
23
+
24
+ /**
25
+ * The region name a block carries, or the empty string when it carries none.
26
+ *
27
+ * Both a bare string and a per-breakpoint bucket are read, for exactly the
28
+ * reason `slotNodeName` reads both: every other value in `block.properties` is
29
+ * per-breakpoint and the editor's `handlePropertyChange` re-buckets whatever it
30
+ * is given, so a bare-string-only reader would detach every region in every
31
+ * published composition the day anything routes this key through a property
32
+ * control.
33
+ */
34
+ export const regionNodeName = (block: Block): string => {
35
+ const raw = block.properties?.[REGION_NAME_PROPERTY];
36
+
37
+ if (typeof raw === 'string') return raw;
38
+
39
+ if (raw && typeof raw === 'object') {
40
+ const desktop = (raw as Record<string, unknown>).desktop;
41
+ if (typeof desktop === 'string') return desktop;
42
+ }
43
+
44
+ return '';
45
+ };
46
+
47
+ export const isRegionNode = (block: Block): boolean =>
48
+ regionNodeName(block) !== '';
49
+
50
+ /**
51
+ * The block seeded for one region, or null when the merchant deleted it.
52
+ *
53
+ * Null is NOT an error. Deleting a region is an ordinary composition edit and
54
+ * has to stay durable — a merchant who removes the language switcher from the
55
+ * header expects it gone on the next load, not re-seeded. The caller decides
56
+ * what to draw in its place, and only the designer draws anything.
57
+ *
58
+ * The FIRST match wins. Two blocks carrying one region name is only reachable
59
+ * by duplicating a region row in the layer tree; rendering both would put two
60
+ * `data-block-id` wrappers under one `<ThemeRegion>`, and the second is
61
+ * unreachable from the JSX either way.
62
+ */
63
+ export const findRegionChild = (
64
+ container: { blocks?: Block[] } | null | undefined,
65
+ name: string
66
+ ): Block | null => {
67
+ if (!name) return null;
68
+
69
+ const children = container?.blocks ?? [];
70
+
71
+ return children.find((child) => regionNodeName(child) === name) ?? null;
72
+ };