@akinon/pz-theme 2.0.71-beta.0 → 2.0.71
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 +7 -4
- package/package.json +4 -4
- package/src/__tests__/custom-catalog-announce.test.ts +276 -0
- package/src/blocks/group-block.tsx +9 -57
- package/src/blocks/render-composed-children.tsx +104 -0
- package/src/custom/README.md +249 -0
- package/src/custom/__tests__/define.test.tsx +619 -0
- package/src/custom/__tests__/module-boundaries.test.ts +156 -0
- package/src/custom/__tests__/public-api.test.tsx +427 -0
- package/src/custom/__tests__/slot-node-wire.test.tsx +309 -0
- package/src/custom/__tests__/slot-nodes.test.ts +144 -0
- package/src/custom/__tests__/theme-editable.test.tsx +486 -0
- package/src/custom/__tests__/theme-slot.test.tsx +387 -0
- package/src/custom/__tests__/use-theme-setting.test.tsx +260 -0
- package/src/custom/block-context.ts +64 -0
- package/src/custom/block-host.tsx +162 -0
- package/src/custom/catalog.ts +97 -0
- package/src/custom/collect.ts +342 -0
- package/src/custom/define.tsx +172 -0
- package/src/custom/editable-tags.ts +120 -0
- package/src/custom/index.ts +105 -0
- package/src/custom/parts.ts +108 -0
- package/src/custom/property-types.ts +70 -0
- package/src/custom/register.ts +545 -0
- package/src/custom/slot-nodes.ts +119 -0
- package/src/custom/theme-editable.tsx +342 -0
- package/src/custom/theme-slot.tsx +210 -0
- package/src/custom/types.ts +251 -0
- package/src/custom/use-theme-setting.ts +225 -0
- package/src/index.ts +66 -0
- package/src/placeholder-registry.ts +168 -16
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
// Stub the recursive ThemeBlock (default export) with a countable marker, the
|
|
5
|
+
// same way group-block.test.tsx does — a slot is only honest if the children
|
|
6
|
+
// it hands over are the ones that reach the renderer.
|
|
7
|
+
jest.mock('../../theme-block', () => ({
|
|
8
|
+
__esModule: true,
|
|
9
|
+
default: ({ block }: { block: { id: string } }) => (
|
|
10
|
+
<div data-testid="tb" data-id={block.id} />
|
|
11
|
+
)
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
import { CustomBlockContext } from '../block-context';
|
|
15
|
+
import type { CustomBlockContextValue } from '../block-context';
|
|
16
|
+
import { CUSTOM_SLOT_BLOCK_TYPE, SLOT_NAME_PROPERTY } from '../types';
|
|
17
|
+
import type { CustomSlotDescriptor } from '../types';
|
|
18
|
+
import type { Block } from '../../theme-block';
|
|
19
|
+
import { ThemeSlot } from '../theme-slot';
|
|
20
|
+
import { resetDevWarnCacheForTests } from '../../utils/dev-warn';
|
|
21
|
+
|
|
22
|
+
const child = (id: string, hidden = false, order = 0): Block => ({
|
|
23
|
+
id,
|
|
24
|
+
type: 'text',
|
|
25
|
+
label: id,
|
|
26
|
+
value: '',
|
|
27
|
+
properties: {},
|
|
28
|
+
styles: {},
|
|
29
|
+
order,
|
|
30
|
+
hidden
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A slot node exactly as the editor writes one: the reserved type, the name in
|
|
35
|
+
* `properties`, and the merchant blocks as its children.
|
|
36
|
+
*/
|
|
37
|
+
const slotNode = (name: string, blocks: Block[]): Block => ({
|
|
38
|
+
id: `slot-${name || 'unnamed'}`,
|
|
39
|
+
type: CUSTOM_SLOT_BLOCK_TYPE,
|
|
40
|
+
label: name || 'Slot',
|
|
41
|
+
properties: { [SLOT_NAME_PROPERTY]: name },
|
|
42
|
+
styles: {},
|
|
43
|
+
order: 0,
|
|
44
|
+
hidden: false,
|
|
45
|
+
blocks
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const contextValue = ({
|
|
49
|
+
blocks,
|
|
50
|
+
isDesigner = false,
|
|
51
|
+
slotSink,
|
|
52
|
+
declaredSlotNames
|
|
53
|
+
}: {
|
|
54
|
+
blocks?: Block[];
|
|
55
|
+
isDesigner?: boolean;
|
|
56
|
+
slotSink?: (descriptor: CustomSlotDescriptor) => void;
|
|
57
|
+
declaredSlotNames?: string[];
|
|
58
|
+
} = {}): CustomBlockContextValue => ({
|
|
59
|
+
block: {
|
|
60
|
+
id: 'b1',
|
|
61
|
+
type: 'custom:acme:promo-strip',
|
|
62
|
+
label: 'Promo strip',
|
|
63
|
+
properties: {},
|
|
64
|
+
styles: {},
|
|
65
|
+
order: 0,
|
|
66
|
+
hidden: false,
|
|
67
|
+
blocks
|
|
68
|
+
},
|
|
69
|
+
placeholderId: 'p',
|
|
70
|
+
sectionId: 's',
|
|
71
|
+
isDesigner,
|
|
72
|
+
currentBreakpoint: 'desktop',
|
|
73
|
+
...(declaredSlotNames ? { declaredSlotNames } : {}),
|
|
74
|
+
...(slotSink ? { collect: { setting: jest.fn(), slot: slotSink } } : {})
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const renderSlot = (
|
|
78
|
+
value: CustomBlockContextValue | null,
|
|
79
|
+
slot: React.ReactNode = <ThemeSlot />
|
|
80
|
+
) =>
|
|
81
|
+
render(
|
|
82
|
+
value === null ? (
|
|
83
|
+
<>{slot}</>
|
|
84
|
+
) : (
|
|
85
|
+
<CustomBlockContext.Provider value={value}>
|
|
86
|
+
{slot}
|
|
87
|
+
</CustomBlockContext.Provider>
|
|
88
|
+
)
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const markers = (container: HTMLElement) =>
|
|
92
|
+
Array.from(container.querySelectorAll('[data-testid="tb"]'));
|
|
93
|
+
|
|
94
|
+
const bands = (container: HTMLElement) =>
|
|
95
|
+
Array.from(container.querySelectorAll('[data-theme-block-placeholder]'));
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
resetDevWarnCacheForTests();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('ThemeSlot — children', () => {
|
|
102
|
+
it('renders the block children through ThemeBlock, in order', () => {
|
|
103
|
+
const { container } = renderSlot(
|
|
104
|
+
contextValue({ blocks: [child('a', false, 2), child('b', false, 0)] })
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
expect(markers(container).map((m) => m.getAttribute('data-id'))).toEqual([
|
|
108
|
+
'b',
|
|
109
|
+
'a'
|
|
110
|
+
]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('drops hidden children on the live site and keeps them in the designer', () => {
|
|
114
|
+
const blocks = [child('visible'), child('concealed', true)];
|
|
115
|
+
|
|
116
|
+
const live = renderSlot(contextValue({ blocks }));
|
|
117
|
+
expect(markers(live.container)).toHaveLength(1);
|
|
118
|
+
|
|
119
|
+
const designer = renderSlot(contextValue({ blocks, isDesigner: true }));
|
|
120
|
+
expect(markers(designer.container)).toHaveLength(2);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('ThemeSlot — empty', () => {
|
|
125
|
+
it('draws a labelled band in the designer', () => {
|
|
126
|
+
const { container } = renderSlot(
|
|
127
|
+
contextValue({ blocks: [], isDesigner: true }),
|
|
128
|
+
<ThemeSlot label="Promo content" />
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
expect(bands(container)).toHaveLength(1);
|
|
132
|
+
expect(container.textContent).toContain('Promo content');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('renders nothing at all on the live site', () => {
|
|
136
|
+
const { container } = renderSlot(contextValue({ blocks: [] }));
|
|
137
|
+
|
|
138
|
+
expect(container).toBeEmptyDOMElement();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('is empty on the live site when every child is hidden, band-only in the designer', () => {
|
|
142
|
+
const blocks = [child('concealed', true)];
|
|
143
|
+
|
|
144
|
+
const live = renderSlot(contextValue({ blocks }));
|
|
145
|
+
expect(live.container).toBeEmptyDOMElement();
|
|
146
|
+
|
|
147
|
+
const designer = renderSlot(contextValue({ blocks, isDesigner: true }));
|
|
148
|
+
expect(markers(designer.container)).toHaveLength(1);
|
|
149
|
+
expect(bands(designer.container)).toHaveLength(0);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('ThemeSlot — named slots', () => {
|
|
154
|
+
const nodes = [
|
|
155
|
+
slotNode('promo', [child('in-promo')]),
|
|
156
|
+
slotNode('aside', [child('in-aside')])
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
it('renders the children of its own node and of no other slot', () => {
|
|
160
|
+
const { container } = renderSlot(
|
|
161
|
+
contextValue({ blocks: nodes }),
|
|
162
|
+
<ThemeSlot name="promo" />
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
expect(markers(container).map((m) => m.getAttribute('data-id'))).toEqual([
|
|
166
|
+
'in-promo'
|
|
167
|
+
]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('fills every declared slot independently in one render', () => {
|
|
171
|
+
const { container } = renderSlot(
|
|
172
|
+
contextValue({ blocks: nodes, declaredSlotNames: ['promo', 'aside'] }),
|
|
173
|
+
<>
|
|
174
|
+
<ThemeSlot name="promo" />
|
|
175
|
+
<ThemeSlot name="aside" />
|
|
176
|
+
</>
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
expect(markers(container).map((m) => m.getAttribute('data-id'))).toEqual([
|
|
180
|
+
'in-promo',
|
|
181
|
+
'in-aside'
|
|
182
|
+
]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('draws the band for a slot whose node does not exist yet, and nothing live', () => {
|
|
186
|
+
const designer = renderSlot(
|
|
187
|
+
contextValue({ blocks: nodes, isDesigner: true }),
|
|
188
|
+
<ThemeSlot name="footer" label="Footer content" />
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
expect(bands(designer.container)).toHaveLength(1);
|
|
192
|
+
expect(designer.container.textContent).toContain('Footer content');
|
|
193
|
+
|
|
194
|
+
const live = renderSlot(
|
|
195
|
+
contextValue({ blocks: nodes }),
|
|
196
|
+
<ThemeSlot name="footer" />
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
expect(live.container).toBeEmptyDOMElement();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('refuses a name the editor could never have written', () => {
|
|
203
|
+
const warn = jest
|
|
204
|
+
.spyOn(console, 'warn')
|
|
205
|
+
.mockImplementation(() => undefined);
|
|
206
|
+
|
|
207
|
+
const { container } = renderSlot(
|
|
208
|
+
contextValue({ blocks: nodes }),
|
|
209
|
+
<ThemeSlot name="Promo Content" />
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
expect(container).toBeEmptyDOMElement();
|
|
213
|
+
expect(warn).toHaveBeenCalledWith(
|
|
214
|
+
expect.stringContaining('"Promo Content" is not valid')
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
warn.mockRestore();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('ThemeSlot — a composition saved before slots were nodes', () => {
|
|
222
|
+
it('renders children that sit directly on the block', () => {
|
|
223
|
+
const { container } = renderSlot(
|
|
224
|
+
contextValue({ blocks: [child('legacy')] })
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
expect(markers(container).map((m) => m.getAttribute('data-id'))).toEqual([
|
|
228
|
+
'legacy'
|
|
229
|
+
]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('never hands another slot node to the unnamed slot', () => {
|
|
233
|
+
// The half-migrated shape: one region already opened as a node, one child
|
|
234
|
+
// still sitting on the block. The unnamed slot owns the second and must
|
|
235
|
+
// not also render the first.
|
|
236
|
+
const { container } = renderSlot(
|
|
237
|
+
contextValue({
|
|
238
|
+
blocks: [slotNode('promo', [child('in-promo')]), child('loose')]
|
|
239
|
+
})
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
expect(markers(container).map((m) => m.getAttribute('data-id'))).toEqual([
|
|
243
|
+
'loose'
|
|
244
|
+
]);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe('ThemeSlot — a bare ThemeSlot', () => {
|
|
249
|
+
it('opens the single declared slot whatever it is called', () => {
|
|
250
|
+
const { container } = renderSlot(
|
|
251
|
+
contextValue({
|
|
252
|
+
blocks: [slotNode('promo', [child('in-promo')])],
|
|
253
|
+
declaredSlotNames: ['promo']
|
|
254
|
+
})
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
expect(markers(container).map((m) => m.getAttribute('data-id'))).toEqual([
|
|
258
|
+
'in-promo'
|
|
259
|
+
]);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('takes the unnamed slot when the block declares one beside named ones', () => {
|
|
263
|
+
const { container } = renderSlot(
|
|
264
|
+
contextValue({
|
|
265
|
+
blocks: [slotNode('', [child('in-unnamed')]), slotNode('promo', [])],
|
|
266
|
+
declaredSlotNames: ['', 'promo']
|
|
267
|
+
})
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
expect(markers(container).map((m) => m.getAttribute('data-id'))).toEqual([
|
|
271
|
+
'in-unnamed'
|
|
272
|
+
]);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('is refused when several named slots leave nothing to resolve', () => {
|
|
276
|
+
const warn = jest
|
|
277
|
+
.spyOn(console, 'warn')
|
|
278
|
+
.mockImplementation(() => undefined);
|
|
279
|
+
|
|
280
|
+
const { container } = renderSlot(
|
|
281
|
+
contextValue({
|
|
282
|
+
blocks: [slotNode('promo', [child('in-promo')])],
|
|
283
|
+
declaredSlotNames: ['promo', 'aside'],
|
|
284
|
+
isDesigner: true
|
|
285
|
+
})
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
// The message has to name the alternatives: the next edit a developer
|
|
289
|
+
// makes is typing one of them.
|
|
290
|
+
expect(warn).toHaveBeenCalledWith(
|
|
291
|
+
expect.stringContaining('<ThemeSlot name="promo" />')
|
|
292
|
+
);
|
|
293
|
+
expect(warn).toHaveBeenCalledWith(
|
|
294
|
+
expect.stringContaining('<ThemeSlot name="aside" />')
|
|
295
|
+
);
|
|
296
|
+
expect(bands(container)).toHaveLength(1);
|
|
297
|
+
expect(markers(container)).toHaveLength(0);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe('ThemeSlot — the same slot opened twice in one component', () => {
|
|
302
|
+
it('renders the node children once, not twice', () => {
|
|
303
|
+
const warn = jest
|
|
304
|
+
.spyOn(console, 'warn')
|
|
305
|
+
.mockImplementation(() => undefined);
|
|
306
|
+
|
|
307
|
+
const { container } = renderSlot(
|
|
308
|
+
contextValue({ blocks: [slotNode('promo', [child('in-promo')])] }),
|
|
309
|
+
<>
|
|
310
|
+
<ThemeSlot name="promo" />
|
|
311
|
+
<ThemeSlot name="promo" />
|
|
312
|
+
</>
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
expect(markers(container)).toHaveLength(1);
|
|
316
|
+
expect(warn).toHaveBeenCalledWith(
|
|
317
|
+
expect.stringContaining('declares more than one ThemeSlot')
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
warn.mockRestore();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('explains itself on the designer canvas rather than going blank', () => {
|
|
324
|
+
const warn = jest
|
|
325
|
+
.spyOn(console, 'warn')
|
|
326
|
+
.mockImplementation(() => undefined);
|
|
327
|
+
|
|
328
|
+
const { container } = renderSlot(
|
|
329
|
+
contextValue({ blocks: [child('only')], isDesigner: true }),
|
|
330
|
+
<>
|
|
331
|
+
<ThemeSlot />
|
|
332
|
+
<ThemeSlot />
|
|
333
|
+
</>
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
expect(bands(container)).toHaveLength(1);
|
|
337
|
+
expect(container.textContent).toContain('Duplicate slot');
|
|
338
|
+
|
|
339
|
+
warn.mockRestore();
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe('ThemeSlot — declaration reported to the sink', () => {
|
|
344
|
+
it('forwards every slot the component opens, with its label', () => {
|
|
345
|
+
const slotSink = jest.fn();
|
|
346
|
+
|
|
347
|
+
renderSlot(
|
|
348
|
+
contextValue({ blocks: [child('only')], slotSink }),
|
|
349
|
+
<>
|
|
350
|
+
<ThemeSlot name="body" label="Promo content" />
|
|
351
|
+
<ThemeSlot name="aside" />
|
|
352
|
+
</>
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
expect(slotSink).toHaveBeenCalledTimes(2);
|
|
356
|
+
expect(slotSink).toHaveBeenCalledWith({
|
|
357
|
+
name: 'body',
|
|
358
|
+
label: 'Promo content'
|
|
359
|
+
});
|
|
360
|
+
expect(slotSink).toHaveBeenCalledWith({ name: 'aside' });
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('reports the single unnamed slot as the empty name', () => {
|
|
364
|
+
const slotSink = jest.fn();
|
|
365
|
+
|
|
366
|
+
renderSlot(contextValue({ blocks: [child('only')], slotSink }));
|
|
367
|
+
|
|
368
|
+
expect(slotSink).toHaveBeenCalledWith({ name: '' });
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
describe('ThemeSlot — used outside a custom block', () => {
|
|
373
|
+
it('renders nothing and warns instead of crashing', () => {
|
|
374
|
+
const warn = jest
|
|
375
|
+
.spyOn(console, 'warn')
|
|
376
|
+
.mockImplementation(() => undefined);
|
|
377
|
+
|
|
378
|
+
const { container } = renderSlot(null);
|
|
379
|
+
|
|
380
|
+
expect(container).toBeEmptyDOMElement();
|
|
381
|
+
expect(warn).toHaveBeenCalledWith(
|
|
382
|
+
expect.stringContaining('outside a custom block')
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
warn.mockRestore();
|
|
386
|
+
});
|
|
387
|
+
});
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { renderHook } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
import { CustomBlockContext } from '../block-context';
|
|
5
|
+
import type { CustomBlockContextValue } from '../block-context';
|
|
6
|
+
import type { CustomSettingDescriptor } from '../types';
|
|
7
|
+
import { useThemeSetting } from '../use-theme-setting';
|
|
8
|
+
import { resetDevWarnCacheForTests } from '../../utils/dev-warn';
|
|
9
|
+
|
|
10
|
+
type SettingSink = (descriptor: CustomSettingDescriptor) => void;
|
|
11
|
+
|
|
12
|
+
const contextValue = ({
|
|
13
|
+
properties = {},
|
|
14
|
+
currentBreakpoint = 'desktop',
|
|
15
|
+
sink
|
|
16
|
+
}: {
|
|
17
|
+
properties?: Record<string, unknown>;
|
|
18
|
+
currentBreakpoint?: string;
|
|
19
|
+
sink?: SettingSink;
|
|
20
|
+
} = {}): CustomBlockContextValue => ({
|
|
21
|
+
block: {
|
|
22
|
+
id: 'b1',
|
|
23
|
+
type: 'custom:acme:promo-strip',
|
|
24
|
+
label: 'Promo strip',
|
|
25
|
+
properties,
|
|
26
|
+
styles: {},
|
|
27
|
+
order: 0,
|
|
28
|
+
hidden: false
|
|
29
|
+
},
|
|
30
|
+
placeholderId: 'p',
|
|
31
|
+
sectionId: 's',
|
|
32
|
+
isDesigner: false,
|
|
33
|
+
currentBreakpoint,
|
|
34
|
+
...(sink ? { collect: { setting: sink, slot: jest.fn() } } : {})
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const wrapperFor = (value: CustomBlockContextValue | null) => {
|
|
38
|
+
const Wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
39
|
+
value === null ? (
|
|
40
|
+
<>{children}</>
|
|
41
|
+
) : (
|
|
42
|
+
<CustomBlockContext.Provider value={value}>
|
|
43
|
+
{children}
|
|
44
|
+
</CustomBlockContext.Provider>
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
return Wrapper;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
resetDevWarnCacheForTests();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('useThemeSetting — value resolution', () => {
|
|
55
|
+
it('reads the merchant value for the active breakpoint', () => {
|
|
56
|
+
const value = contextValue({
|
|
57
|
+
properties: { tone: { desktop: 'light', mobile: 'dark' } },
|
|
58
|
+
currentBreakpoint: 'mobile'
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const { result } = renderHook(
|
|
62
|
+
() =>
|
|
63
|
+
useThemeSetting('tone', {
|
|
64
|
+
type: 'select',
|
|
65
|
+
label: 'Tone',
|
|
66
|
+
options: ['light', 'dark'],
|
|
67
|
+
default: 'light'
|
|
68
|
+
}),
|
|
69
|
+
{ wrapper: wrapperFor(value) }
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(result.current).toBe('dark');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('falls back to the declared default when the merchant stored nothing', () => {
|
|
76
|
+
const { result } = renderHook(
|
|
77
|
+
() =>
|
|
78
|
+
useThemeSetting('tone', {
|
|
79
|
+
type: 'select',
|
|
80
|
+
label: 'Tone',
|
|
81
|
+
options: ['light', 'dark'],
|
|
82
|
+
default: 'light'
|
|
83
|
+
}),
|
|
84
|
+
{ wrapper: wrapperFor(contextValue()) }
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(result.current).toBe('light');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('resolves a default that was itself authored per breakpoint', () => {
|
|
91
|
+
const { result } = renderHook(
|
|
92
|
+
() =>
|
|
93
|
+
useThemeSetting('columns', {
|
|
94
|
+
type: 'slider',
|
|
95
|
+
label: 'Columns',
|
|
96
|
+
default: { desktop: 4, mobile: 2 } as unknown as number
|
|
97
|
+
}),
|
|
98
|
+
{ wrapper: wrapperFor(contextValue({ currentBreakpoint: 'mobile' })) }
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
expect(result.current).toBe(2);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('returns undefined — not the empty string — when nothing is stored and no default was declared', () => {
|
|
105
|
+
const { result } = renderHook(
|
|
106
|
+
() => useThemeSetting('autoplay', { type: 'toggle', label: 'Autoplay' }),
|
|
107
|
+
{ wrapper: wrapperFor(contextValue()) }
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
expect(result.current).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('reads block.properties, never block.value', () => {
|
|
114
|
+
const value = contextValue();
|
|
115
|
+
value.block.value = 'from-value';
|
|
116
|
+
|
|
117
|
+
const { result } = renderHook(
|
|
118
|
+
() => useThemeSetting('heading', { type: 'text', label: 'Heading' }),
|
|
119
|
+
{ wrapper: wrapperFor(value) }
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
expect(result.current).toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('useThemeSetting — the declaration reported to the sink', () => {
|
|
127
|
+
const declareTone = (sink: SettingSink) =>
|
|
128
|
+
renderHook(
|
|
129
|
+
() =>
|
|
130
|
+
useThemeSetting('tone', {
|
|
131
|
+
type: 'select',
|
|
132
|
+
label: 'Tone',
|
|
133
|
+
options: ['light', 'dark'],
|
|
134
|
+
default: 'light'
|
|
135
|
+
}),
|
|
136
|
+
{ wrapper: wrapperFor(contextValue({ sink })) }
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
it('normalizes the options shorthand into label/value pairs', () => {
|
|
140
|
+
const sink: SettingSink = jest.fn();
|
|
141
|
+
declareTone(sink);
|
|
142
|
+
|
|
143
|
+
expect(sink).toHaveBeenCalledWith(
|
|
144
|
+
expect.objectContaining({
|
|
145
|
+
key: 'tone',
|
|
146
|
+
type: 'select',
|
|
147
|
+
label: 'Tone',
|
|
148
|
+
options: [
|
|
149
|
+
{ label: 'light', value: 'light' },
|
|
150
|
+
{ label: 'dark', value: 'dark' }
|
|
151
|
+
]
|
|
152
|
+
})
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('passes an already-normalized option list through untouched', () => {
|
|
157
|
+
const sink: SettingSink = jest.fn();
|
|
158
|
+
const pairs = [{ label: 'Two up', value: 2 }];
|
|
159
|
+
|
|
160
|
+
renderHook(
|
|
161
|
+
() =>
|
|
162
|
+
useThemeSetting('columns', {
|
|
163
|
+
type: 'select',
|
|
164
|
+
label: 'Columns',
|
|
165
|
+
options: pairs
|
|
166
|
+
}),
|
|
167
|
+
{ wrapper: wrapperFor(contextValue({ sink })) }
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
expect(sink).toHaveBeenCalledWith(
|
|
171
|
+
expect.objectContaining({ options: pairs })
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('materializes a scalar default per breakpoint so the editor cannot drop it', () => {
|
|
176
|
+
const sink: SettingSink = jest.fn();
|
|
177
|
+
declareTone(sink);
|
|
178
|
+
|
|
179
|
+
expect(sink).toHaveBeenCalledWith(
|
|
180
|
+
expect.objectContaining({ defaultValue: { desktop: 'light' } })
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('omits optional fields that were never declared', () => {
|
|
185
|
+
const sink: SettingSink = jest.fn();
|
|
186
|
+
|
|
187
|
+
renderHook(
|
|
188
|
+
() => useThemeSetting('heading', { type: 'text', label: 'Heading' }),
|
|
189
|
+
{ wrapper: wrapperFor(contextValue({ sink })) }
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
expect(sink).toHaveBeenCalledWith({
|
|
193
|
+
key: 'heading',
|
|
194
|
+
type: 'text',
|
|
195
|
+
label: 'Heading'
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('useThemeSetting — used outside a custom block', () => {
|
|
201
|
+
it('returns the default and warns instead of crashing', () => {
|
|
202
|
+
const warn = jest
|
|
203
|
+
.spyOn(console, 'warn')
|
|
204
|
+
.mockImplementation(() => undefined);
|
|
205
|
+
|
|
206
|
+
const { result } = renderHook(
|
|
207
|
+
() =>
|
|
208
|
+
useThemeSetting('tone', {
|
|
209
|
+
type: 'select',
|
|
210
|
+
label: 'Tone',
|
|
211
|
+
options: ['light', 'dark'],
|
|
212
|
+
default: 'light'
|
|
213
|
+
}),
|
|
214
|
+
{ wrapper: wrapperFor(null) }
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
expect(result.current).toBe('light');
|
|
218
|
+
expect(warn).toHaveBeenCalledWith(
|
|
219
|
+
expect.stringContaining('was read outside a custom block')
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
warn.mockRestore();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe('useThemeSetting — invalid declarations throw in development', () => {
|
|
227
|
+
const renderWith = (key: string, options: Record<string, unknown>) =>
|
|
228
|
+
renderHook(
|
|
229
|
+
() =>
|
|
230
|
+
useThemeSetting(
|
|
231
|
+
key,
|
|
232
|
+
options as unknown as Parameters<typeof useThemeSetting>[1]
|
|
233
|
+
),
|
|
234
|
+
{ wrapper: wrapperFor(contextValue()) }
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
it('rejects a key the part grammar cannot carry', () => {
|
|
238
|
+
expect(() =>
|
|
239
|
+
renderWith('Promo/Art', { type: 'text', label: 'Art' })
|
|
240
|
+
).toThrow(/is not a valid setting key/);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('rejects a key the editor panel already treats specially', () => {
|
|
244
|
+
expect(() => renderWith('items', { type: 'text', label: 'Items' })).toThrow(
|
|
245
|
+
/reserved/
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('rejects a control type whose value cannot be read back', () => {
|
|
250
|
+
expect(() =>
|
|
251
|
+
renderWith('tags', { type: 'multiselect', label: 'Tags' })
|
|
252
|
+
).toThrow(/cannot deliver a value/);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('rejects a type that is not a control at all', () => {
|
|
256
|
+
expect(() =>
|
|
257
|
+
renderWith('tone', { type: 'dropdown', label: 'Tone' })
|
|
258
|
+
).toThrow(/not one of the editor's control types/);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext } from 'react';
|
|
4
|
+
import type { Block } from '../theme-block';
|
|
5
|
+
import type {
|
|
6
|
+
CustomSettingDescriptor,
|
|
7
|
+
CustomSlotDescriptor
|
|
8
|
+
} from './types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What `ThemeEditable`, `useThemeSetting` and `ThemeSlot` read.
|
|
12
|
+
*
|
|
13
|
+
* A context rather than props, because the whole point of the authoring model
|
|
14
|
+
* is that a developer writes ordinary JSX. Threading `block`/`breakpoint`
|
|
15
|
+
* through their own component tree to reach a `<ThemeEditable>` five levels
|
|
16
|
+
* down would be the tax this API exists to remove.
|
|
17
|
+
*/
|
|
18
|
+
export interface CustomBlockContextValue {
|
|
19
|
+
block: Block;
|
|
20
|
+
placeholderId: string;
|
|
21
|
+
sectionId: string;
|
|
22
|
+
isDesigner: boolean;
|
|
23
|
+
currentBreakpoint: string;
|
|
24
|
+
selectedBlockId?: string | null;
|
|
25
|
+
/**
|
|
26
|
+
* The slot names `defineCustomBlock` DECLARED, in declaration order, so a
|
|
27
|
+
* bare `<ThemeSlot />` can resolve to the block's single slot whatever it is
|
|
28
|
+
* called.
|
|
29
|
+
*
|
|
30
|
+
* From the definition and not from the catalog descriptor, which is the
|
|
31
|
+
* whole reason it is threaded here. The descriptor's `slots` array is
|
|
32
|
+
* APPENDED TO by runtime collection as each slot renders, so a component
|
|
33
|
+
* whose named slot renders before its bare one would see a one-entry list
|
|
34
|
+
* and resolve the bare slot onto the name the first one just claimed.
|
|
35
|
+
* The declaration never moves.
|
|
36
|
+
*/
|
|
37
|
+
declaredSlotNames?: readonly string[];
|
|
38
|
+
/**
|
|
39
|
+
* Runtime collection sink. Present only while the block is rendering inside
|
|
40
|
+
* a host that is collecting — see `collect.ts`. The build-time extractor is
|
|
41
|
+
* the authoritative source of the descriptor; this is the fallback that
|
|
42
|
+
* makes the API work before the extractor exists, and the cross-check that
|
|
43
|
+
* catches a stale generated descriptor after it does.
|
|
44
|
+
*/
|
|
45
|
+
collect?: {
|
|
46
|
+
setting: (descriptor: CustomSettingDescriptor) => void;
|
|
47
|
+
slot: (descriptor: CustomSlotDescriptor) => void;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const CustomBlockContext =
|
|
52
|
+
createContext<CustomBlockContextValue | null>(null);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Returns null outside a custom block rather than throwing.
|
|
56
|
+
*
|
|
57
|
+
* The three public components have to say something useful when a developer
|
|
58
|
+
* uses them in the wrong place — a plain React tree, a built-in block, a unit
|
|
59
|
+
* test. Each handles the null itself with a named message; a bare throw here
|
|
60
|
+
* would surface as a stack trace pointing at this file rather than at the
|
|
61
|
+
* `<ThemeEditable>` the developer actually mis-placed.
|
|
62
|
+
*/
|
|
63
|
+
export const useCustomBlockContext = (): CustomBlockContextValue | null =>
|
|
64
|
+
useContext(CustomBlockContext);
|