@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.
- package/CHANGELOG.md +6 -41
- 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
|
@@ -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
|
+
};
|
package/src/custom/index.ts
CHANGED
|
@@ -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
|
+
};
|