@akinon/pz-theme 2.0.52-beta.0 → 2.0.52
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 +4 -4
- package/package.json +4 -4
- package/src/chrome/__tests__/sticky-slot.test.tsx +223 -0
- package/src/chrome/__tests__/theme-config-source.test.ts +96 -0
- package/src/chrome/chrome-slot.tsx +44 -3
- package/src/chrome/sticky-slot-overflow.tsx +44 -0
- package/src/chrome/sticky-slot.ts +70 -0
- package/src/chrome/theme-config-source.ts +93 -0
- package/src/index.ts +1 -0
- package/src/theme-placeholder-wrapper.tsx +4 -17
- package/src/theme-placeholder.tsx +5 -52
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.52
|
|
3
|
+
## 2.0.52
|
|
4
4
|
|
|
5
5
|
### Patch Changes
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
- @akinon/next@2.0.52
|
|
9
|
-
- @akinon/theme-contract@2.0.52
|
|
7
|
+
- c3a713d: ZERO-4752: Pin a composed chrome slot for real instead of writing unread header variables
|
|
8
|
+
- @akinon/next@2.0.52
|
|
9
|
+
- @akinon/theme-contract@2.0.52
|
|
10
10
|
|
|
11
11
|
## 2.0.51
|
|
12
12
|
|
package/package.json
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akinon/pz-theme",
|
|
3
3
|
"description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
|
|
4
|
-
"version": "2.0.52
|
|
4
|
+
"version": "2.0.52",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"test": "jest"
|
|
9
9
|
},
|
|
10
10
|
"peerDependencies": {
|
|
11
|
-
"@akinon/next": "2.0.52
|
|
11
|
+
"@akinon/next": "2.0.52",
|
|
12
12
|
"react": "^18.0.0 || ^19.0.0",
|
|
13
13
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@akinon/theme-contract": "2.0.52
|
|
16
|
+
"@akinon/theme-contract": "2.0.52",
|
|
17
17
|
"clsx": "2.1.1",
|
|
18
18
|
"lottie-web": "^5.13.0",
|
|
19
19
|
"tailwind-merge": "^2.5.4",
|
|
20
20
|
"zod": "4.4.1"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@akinon/next": "2.0.52
|
|
23
|
+
"@akinon/next": "2.0.52",
|
|
24
24
|
"@testing-library/jest-dom": "5.16.4",
|
|
25
25
|
"@testing-library/react": "16.3.2",
|
|
26
26
|
"@testing-library/user-event": "14.1.1",
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen, cleanup } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Sticky used to be a theme setting that wrote three CSS variables nothing
|
|
6
|
+
* read, so a designer could turn "sticky header" on and nothing moved. The pin
|
|
7
|
+
* now lands on the slot element, which is the only box in the chain tall
|
|
8
|
+
* enough for a pin to travel — everything inside it hugs the header's own
|
|
9
|
+
* height.
|
|
10
|
+
*
|
|
11
|
+
* Two things have to hold together, and only ever did in a browser before:
|
|
12
|
+
* the slot emits the pinned declarations, AND the body overflow patch that
|
|
13
|
+
* sticky depends on fires with them. A pinned slot under the app template's
|
|
14
|
+
* `body.overflow-x-hidden` is silently unpinned without the second half.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
18
|
+
const compositionState = jest.fn<any, [string]>();
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
20
|
+
const themeConfig = jest.fn<any, []>();
|
|
21
|
+
|
|
22
|
+
jest.mock('../composition-source', () => ({
|
|
23
|
+
getSlugCompositionState: (slug: string) => compositionState(slug)
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
jest.mock('../theme-config-source', () => ({
|
|
27
|
+
getThemeConfig: () => themeConfig()
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
// The placeholder is an async server component that reaches for the data
|
|
31
|
+
// layer; what the slot decides about itself is what is under test.
|
|
32
|
+
jest.mock('../../theme-placeholder', () => ({
|
|
33
|
+
__esModule: true,
|
|
34
|
+
default: ({ slug }: { slug: string }) => (
|
|
35
|
+
<div data-testid={`composition-${slug}`} />
|
|
36
|
+
)
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
import ChromeSlot from '../chrome-slot';
|
|
40
|
+
import {
|
|
41
|
+
resolveSlotSticky,
|
|
42
|
+
slotStickyStyle,
|
|
43
|
+
SLOT_STICKY_DEFAULTS
|
|
44
|
+
} from '../sticky-slot';
|
|
45
|
+
|
|
46
|
+
const composed = { kind: 'composed', sectionCount: 1 } as const;
|
|
47
|
+
const uncomposed = { kind: 'empty' } as const;
|
|
48
|
+
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
50
|
+
const renderSlot = async (props: any) => {
|
|
51
|
+
const tree = await ChromeSlot(props);
|
|
52
|
+
return render(tree);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const slotElement = (slug: string) =>
|
|
56
|
+
document.querySelector(`[data-chrome-slot="${slug}"]`) as HTMLElement;
|
|
57
|
+
|
|
58
|
+
describe('resolveSlotSticky', () => {
|
|
59
|
+
it('pins a composed header when the designer turned the setting on', () => {
|
|
60
|
+
expect(
|
|
61
|
+
resolveSlotSticky({
|
|
62
|
+
slug: 'header',
|
|
63
|
+
hasComposition: true,
|
|
64
|
+
themeSettings: { mainMenuSticky: true }
|
|
65
|
+
})
|
|
66
|
+
).toEqual(SLOT_STICKY_DEFAULTS);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('leaves an uncomposed header alone', () => {
|
|
70
|
+
// Still the brand's own header inside the slot, and the brand's stylesheet
|
|
71
|
+
// already decides how it behaves — a second, competing rule is worse than
|
|
72
|
+
// no rule.
|
|
73
|
+
expect(
|
|
74
|
+
resolveSlotSticky({
|
|
75
|
+
slug: 'header',
|
|
76
|
+
hasComposition: false,
|
|
77
|
+
themeSettings: { mainMenuSticky: true }
|
|
78
|
+
})
|
|
79
|
+
).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('does not spread the header setting to other slugs', () => {
|
|
83
|
+
expect(
|
|
84
|
+
resolveSlotSticky({
|
|
85
|
+
slug: 'footer',
|
|
86
|
+
hasComposition: true,
|
|
87
|
+
themeSettings: { mainMenuSticky: true }
|
|
88
|
+
})
|
|
89
|
+
).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('honours slot configuration whether or not the slug is composed', () => {
|
|
93
|
+
expect(
|
|
94
|
+
resolveSlotSticky({ slug: 'checkout-top-body', sticky: true, hasComposition: false })
|
|
95
|
+
).toEqual(SLOT_STICKY_DEFAULTS);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('lets a slot refuse the theme setting', () => {
|
|
99
|
+
expect(
|
|
100
|
+
resolveSlotSticky({
|
|
101
|
+
slug: 'header',
|
|
102
|
+
sticky: false,
|
|
103
|
+
hasComposition: true,
|
|
104
|
+
themeSettings: { mainMenuSticky: true }
|
|
105
|
+
})
|
|
106
|
+
).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('overrides only what the configuration names', () => {
|
|
110
|
+
expect(
|
|
111
|
+
resolveSlotSticky({
|
|
112
|
+
slug: 'header',
|
|
113
|
+
sticky: { top: '64px' },
|
|
114
|
+
hasComposition: true
|
|
115
|
+
})
|
|
116
|
+
).toEqual({ top: '64px', zIndex: SLOT_STICKY_DEFAULTS.zIndex });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('emits nothing at all for a slot that is not pinned', () => {
|
|
120
|
+
expect(slotStickyStyle(null)).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('ChromeSlot sticky', () => {
|
|
125
|
+
beforeEach(() => {
|
|
126
|
+
compositionState.mockReset();
|
|
127
|
+
themeConfig.mockReset();
|
|
128
|
+
themeConfig.mockReturnValue({ settings: null, dataSources: [] });
|
|
129
|
+
// What the app template ships, and what breaks sticky.
|
|
130
|
+
document.body.className = 'overflow-x-hidden';
|
|
131
|
+
document.body.style.overflowX = '';
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('pins the slot element for a composed header with the setting on', async () => {
|
|
135
|
+
compositionState.mockReturnValue(composed);
|
|
136
|
+
themeConfig.mockReturnValue({
|
|
137
|
+
settings: { mainMenuSticky: true },
|
|
138
|
+
dataSources: []
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await renderSlot({ slug: 'header', fallback: <div>brand header</div> });
|
|
142
|
+
|
|
143
|
+
const slot = slotElement('header');
|
|
144
|
+
expect(slot.style.position).toBe('sticky');
|
|
145
|
+
expect(slot.style.top).toBe('0px');
|
|
146
|
+
expect(slot.style.zIndex).toBe('40');
|
|
147
|
+
expect(slot.dataset.sticky).toBe('true');
|
|
148
|
+
// The composition still renders — pinning is a wrapper concern only.
|
|
149
|
+
expect(screen.getByTestId('composition-header')).toBeInTheDocument();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('keeps horizontal overflow clipped rather than hidden while pinned', async () => {
|
|
153
|
+
compositionState.mockReturnValue(composed);
|
|
154
|
+
themeConfig.mockReturnValue({
|
|
155
|
+
settings: { mainMenuSticky: true },
|
|
156
|
+
dataSources: []
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const view = await renderSlot({ slug: 'header' });
|
|
160
|
+
|
|
161
|
+
// `hidden` would make body a scroll container and kill the pin; `clip`
|
|
162
|
+
// clips the same pixels without one, so wide sections still cannot produce
|
|
163
|
+
// a horizontal scrollbar.
|
|
164
|
+
expect(document.body.style.overflowX).toBe('clip');
|
|
165
|
+
expect(document.body.className).toBe('overflow-x-hidden');
|
|
166
|
+
|
|
167
|
+
view.unmount();
|
|
168
|
+
expect(document.body.style.overflowX).toBe('');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('holds the overflow patch until the last pinned slot leaves', async () => {
|
|
172
|
+
compositionState.mockReturnValue(composed);
|
|
173
|
+
|
|
174
|
+
const promo = await renderSlot({ slug: 'header', sticky: true });
|
|
175
|
+
const bar = await renderSlot({ slug: 'checkout-top-body', sticky: true });
|
|
176
|
+
|
|
177
|
+
expect(document.body.style.overflowX).toBe('clip');
|
|
178
|
+
|
|
179
|
+
promo.unmount();
|
|
180
|
+
expect(document.body.style.overflowX).toBe('clip');
|
|
181
|
+
|
|
182
|
+
bar.unmount();
|
|
183
|
+
expect(document.body.style.overflowX).toBe('');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('leaves an unpinned slot and the page overflow untouched', async () => {
|
|
187
|
+
compositionState.mockReturnValue(composed);
|
|
188
|
+
|
|
189
|
+
await renderSlot({ slug: 'header' });
|
|
190
|
+
|
|
191
|
+
const slot = slotElement('header');
|
|
192
|
+
expect(slot.getAttribute('style')).toBeNull();
|
|
193
|
+
expect(slot.dataset.sticky).toBeUndefined();
|
|
194
|
+
expect(document.body.style.overflowX).toBe('');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('pins an uncomposed slot only when the mounting code asked for it', async () => {
|
|
198
|
+
compositionState.mockReturnValue(uncomposed);
|
|
199
|
+
themeConfig.mockReturnValue({
|
|
200
|
+
settings: { mainMenuSticky: true },
|
|
201
|
+
dataSources: []
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
await renderSlot({ slug: 'header', fallback: <div>brand header</div> });
|
|
205
|
+
expect(slotElement('header').style.position).toBe('');
|
|
206
|
+
cleanup();
|
|
207
|
+
|
|
208
|
+
await renderSlot({
|
|
209
|
+
slug: 'header',
|
|
210
|
+
sticky: true,
|
|
211
|
+
fallback: <div>brand header</div>
|
|
212
|
+
});
|
|
213
|
+
expect(slotElement('header').style.position).toBe('sticky');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('does not read the theme config when the mounting code decided', async () => {
|
|
217
|
+
compositionState.mockReturnValue(composed);
|
|
218
|
+
|
|
219
|
+
await renderSlot({ slug: 'header', sticky: false });
|
|
220
|
+
|
|
221
|
+
expect(themeConfig).not.toHaveBeenCalled();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The theme-config parser moved out of ThemePlaceholder so a ChromeSlot can
|
|
3
|
+
* ask the same question (is this slot pinned?) without a second read and
|
|
4
|
+
* without a second copy of the parse. These pin the shapes a backend actually
|
|
5
|
+
* sends — a v1 attribute arrives wrapped in `{ value }`, a v2 one does not —
|
|
6
|
+
* because getting either wrong drops every data source on the page.
|
|
7
|
+
*/
|
|
8
|
+
const getWidgetData = jest.fn();
|
|
9
|
+
|
|
10
|
+
jest.mock('@akinon/next/data/server', () => ({
|
|
11
|
+
getWidgetData: (...args: unknown[]) => getWidgetData(...args)
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
import { getThemeConfig, readThemeConfig } from '../theme-config-source';
|
|
15
|
+
|
|
16
|
+
describe('readThemeConfig', () => {
|
|
17
|
+
it('reads settings and data sources from a JSON string attribute', () => {
|
|
18
|
+
expect(
|
|
19
|
+
readThemeConfig({
|
|
20
|
+
attributes: {
|
|
21
|
+
theme_settings: JSON.stringify({ mainMenuSticky: true }),
|
|
22
|
+
theme_dataSources: JSON.stringify([{ id: 'ds-1' }])
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
).toEqual({
|
|
26
|
+
settings: { mainMenuSticky: true },
|
|
27
|
+
dataSources: [{ id: 'ds-1' }]
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('reads the v1 wrapped attribute shape', () => {
|
|
32
|
+
expect(
|
|
33
|
+
readThemeConfig({
|
|
34
|
+
attributes: {
|
|
35
|
+
theme_settings: { value: JSON.stringify({ mainMenuSticky: true }) },
|
|
36
|
+
theme_dataSources: { value: [{ id: 'ds-1' }] }
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
).toEqual({
|
|
40
|
+
settings: { mainMenuSticky: true },
|
|
41
|
+
dataSources: [{ id: 'ds-1' }]
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('reads an already-parsed attribute', () => {
|
|
46
|
+
expect(
|
|
47
|
+
readThemeConfig({
|
|
48
|
+
attributes: {
|
|
49
|
+
theme_settings: { mainMenuSticky: false },
|
|
50
|
+
theme_dataSources: [{ id: 'ds-1' }]
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
).toEqual({
|
|
54
|
+
settings: { mainMenuSticky: false },
|
|
55
|
+
dataSources: [{ id: 'ds-1' }]
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('keeps the half it can read when the other half is malformed', () => {
|
|
60
|
+
const error = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
61
|
+
|
|
62
|
+
expect(
|
|
63
|
+
readThemeConfig({
|
|
64
|
+
attributes: {
|
|
65
|
+
theme_settings: '{not json',
|
|
66
|
+
theme_dataSources: JSON.stringify([{ id: 'ds-1' }])
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
).toEqual({ settings: null, dataSources: [{ id: 'ds-1' }] });
|
|
70
|
+
|
|
71
|
+
error.mockRestore();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('answers empty for a brand with no theme at all', () => {
|
|
75
|
+
expect(readThemeConfig(null)).toEqual({ settings: null, dataSources: [] });
|
|
76
|
+
expect(readThemeConfig({ attributes: {} })).toEqual({
|
|
77
|
+
settings: null,
|
|
78
|
+
dataSources: []
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('getThemeConfig', () => {
|
|
84
|
+
beforeEach(() => getWidgetData.mockReset());
|
|
85
|
+
|
|
86
|
+
it('answers empty rather than throwing when no widget exists', async () => {
|
|
87
|
+
// theme-config is optional: a brand that has never opened the editor must
|
|
88
|
+
// still render every placeholder on the page.
|
|
89
|
+
getWidgetData.mockRejectedValue(new Error('404'));
|
|
90
|
+
|
|
91
|
+
await expect(getThemeConfig()).resolves.toEqual({
|
|
92
|
+
settings: null,
|
|
93
|
+
dataSources: []
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -2,7 +2,14 @@ import React from 'react';
|
|
|
2
2
|
import ThemePlaceholder from '../theme-placeholder';
|
|
3
3
|
import { warnAboutUncomposedSlot } from './composition-state';
|
|
4
4
|
import { getSlugCompositionState } from './composition-source';
|
|
5
|
+
import { getThemeConfig } from './theme-config-source';
|
|
5
6
|
import { ChromeFallback } from './chrome-fallback';
|
|
7
|
+
import {
|
|
8
|
+
resolveSlotSticky,
|
|
9
|
+
slotStickyStyle,
|
|
10
|
+
SlotStickyConfig
|
|
11
|
+
} from './sticky-slot';
|
|
12
|
+
import { StickySlotOverflow } from './sticky-slot-overflow';
|
|
6
13
|
|
|
7
14
|
interface ChromeSlotProps {
|
|
8
15
|
/** Placeholder slug the editor composes into ('header', 'product-page-body', …) */
|
|
@@ -34,6 +41,16 @@ interface ChromeSlotProps {
|
|
|
34
41
|
* all: two competing CTAs for one slug is worse than none.
|
|
35
42
|
*/
|
|
36
43
|
emptyZoneBand?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Pin the slot to the top of the viewport: `true` for the defaults, an
|
|
46
|
+
* object to override `top` / `zIndex`, `false` to refuse the theme setting.
|
|
47
|
+
*
|
|
48
|
+
* Left unset, a composed header slot follows the designer's `mainMenuSticky`
|
|
49
|
+
* theme setting. Pinning belongs to the slot element rather than to a class
|
|
50
|
+
* the brand puts on it, because the slot is the only box in the chain that
|
|
51
|
+
* still has the page to travel down.
|
|
52
|
+
*/
|
|
53
|
+
sticky?: SlotStickyConfig;
|
|
37
54
|
}
|
|
38
55
|
|
|
39
56
|
/**
|
|
@@ -48,7 +65,8 @@ export default async function ChromeSlot({
|
|
|
48
65
|
pageContext = null,
|
|
49
66
|
className,
|
|
50
67
|
fallbackClassName,
|
|
51
|
-
emptyZoneBand = true
|
|
68
|
+
emptyZoneBand = true,
|
|
69
|
+
sticky
|
|
52
70
|
}: ChromeSlotProps) {
|
|
53
71
|
// Shared with ComposableRegion and memoized per request, so a slug mounted by
|
|
54
72
|
// both is fetched once and both reach the same conclusion.
|
|
@@ -59,9 +77,25 @@ export default async function ChromeSlot({
|
|
|
59
77
|
// for why "missing" and "empty" must not read as the same thing.
|
|
60
78
|
warnAboutUncomposedSlot(slug, state);
|
|
61
79
|
|
|
80
|
+
// Only read when the mounting code said nothing, and memoized per request, so
|
|
81
|
+
// a page full of slots costs at most one theme-config read.
|
|
82
|
+
const themeSettings =
|
|
83
|
+
sticky === undefined ? (await getThemeConfig()).settings : null;
|
|
84
|
+
const pinned = resolveSlotSticky({
|
|
85
|
+
slug,
|
|
86
|
+
sticky,
|
|
87
|
+
hasComposition,
|
|
88
|
+
themeSettings
|
|
89
|
+
});
|
|
90
|
+
|
|
62
91
|
if (hasComposition) {
|
|
63
92
|
return (
|
|
64
|
-
<div
|
|
93
|
+
<div
|
|
94
|
+
data-chrome-slot={slug}
|
|
95
|
+
data-sticky={pinned ? 'true' : undefined}
|
|
96
|
+
className={className}
|
|
97
|
+
style={slotStickyStyle(pinned)}
|
|
98
|
+
>
|
|
65
99
|
{/* The fallback ships hidden: on the live site it never renders, but
|
|
66
100
|
in the designer it must be able to COME BACK when the last
|
|
67
101
|
section is removed — without it in the tree, deleting a menu or
|
|
@@ -82,13 +116,19 @@ export default async function ChromeSlot({
|
|
|
82
116
|
) : emptyZoneBand ? (
|
|
83
117
|
<ChromeFallback slug={slug} emptyZone initiallyOverridden />
|
|
84
118
|
) : null}
|
|
119
|
+
{pinned && <StickySlotOverflow />}
|
|
85
120
|
<ThemePlaceholder slug={slug} pageContext={pageContext} />
|
|
86
121
|
</div>
|
|
87
122
|
);
|
|
88
123
|
}
|
|
89
124
|
|
|
90
125
|
return (
|
|
91
|
-
<div
|
|
126
|
+
<div
|
|
127
|
+
data-chrome-slot={slug}
|
|
128
|
+
data-sticky={pinned ? 'true' : undefined}
|
|
129
|
+
className={className}
|
|
130
|
+
style={slotStickyStyle(pinned)}
|
|
131
|
+
>
|
|
92
132
|
{fallback ? (
|
|
93
133
|
<ChromeFallback slug={slug} wrapperClassName={fallbackClassName}>
|
|
94
134
|
{fallback}
|
|
@@ -99,6 +139,7 @@ export default async function ChromeSlot({
|
|
|
99
139
|
// live site keeps rendering nothing.
|
|
100
140
|
<ChromeFallback slug={slug} emptyZone />
|
|
101
141
|
) : null}
|
|
142
|
+
{pinned && <StickySlotOverflow />}
|
|
102
143
|
{/* Empty until the designer overrides this area from the editor */}
|
|
103
144
|
<ThemePlaceholder slug={slug} pageContext={pageContext} />
|
|
104
145
|
</div>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Keeps a pinned slot's `position: sticky` alive.
|
|
7
|
+
*
|
|
8
|
+
* The app template puts `overflow-x-hidden` on `body` so a wide section cannot
|
|
9
|
+
* produce a horizontal scrollbar, and an ancestor that *hides* overflow becomes
|
|
10
|
+
* a scroll container — which is the box a sticky descendant pins itself inside,
|
|
11
|
+
* so the pin dies at the header's own height. `clip` clips the same pixels
|
|
12
|
+
* without creating that scroll container, which is why the patch exists at all.
|
|
13
|
+
*
|
|
14
|
+
* It used to live in the `mainMenuSticky` branch of the theme-settings
|
|
15
|
+
* injector, which meant it only ever fired for a setting nothing else honoured,
|
|
16
|
+
* and never fired for a slot the designer actually pinned.
|
|
17
|
+
*
|
|
18
|
+
* Ref-counted because a page can pin more than one slot (a promo bar above a
|
|
19
|
+
* header): the first to unmount must not undo the patch the others still need.
|
|
20
|
+
*/
|
|
21
|
+
let pinnedSlots = 0;
|
|
22
|
+
let overflowXToRestore: string | null = null;
|
|
23
|
+
|
|
24
|
+
export function StickySlotOverflow() {
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (pinnedSlots === 0) {
|
|
27
|
+
overflowXToRestore = document.body.style.overflowX;
|
|
28
|
+
document.body.style.overflowX = 'clip';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
pinnedSlots += 1;
|
|
32
|
+
|
|
33
|
+
return () => {
|
|
34
|
+
pinnedSlots -= 1;
|
|
35
|
+
|
|
36
|
+
if (pinnedSlots === 0) {
|
|
37
|
+
document.body.style.overflowX = overflowXToRestore ?? '';
|
|
38
|
+
overflowXToRestore = null;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
2
|
+
|
|
3
|
+
import { PLACEHOLDER_SLUGS } from './placeholder-slugs';
|
|
4
|
+
|
|
5
|
+
/** The values a pinned slot renders with. */
|
|
6
|
+
export interface SlotSticky {
|
|
7
|
+
top: number | string;
|
|
8
|
+
zIndex: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* How a mounted slot asks to be pinned: `true` for the defaults, an object to
|
|
13
|
+
* override one of them, `false` to refuse the designer's theme setting.
|
|
14
|
+
*/
|
|
15
|
+
export type SlotStickyConfig = boolean | Partial<SlotSticky>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Inherited from the `--theme-header-position` / `-top` / `-z-index` variables
|
|
19
|
+
* this replaces, so anything a brand had layered against the (never honoured)
|
|
20
|
+
* sticky header lands on the same stacking order.
|
|
21
|
+
*/
|
|
22
|
+
export const SLOT_STICKY_DEFAULTS: SlotSticky = { top: 0, zIndex: 40 };
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether a chrome slot pins itself, and with what.
|
|
26
|
+
*
|
|
27
|
+
* Two sources, deliberately not symmetric:
|
|
28
|
+
*
|
|
29
|
+
* `sticky` is the mounting code's own decision, so it holds for whichever
|
|
30
|
+
* markup the slot ends up rendering.
|
|
31
|
+
*
|
|
32
|
+
* `mainMenuSticky` is the designer's, and it only pins a slot that is actually
|
|
33
|
+
* composed. An uncomposed header slot is still wrapping the brand's own
|
|
34
|
+
* header, whose stylesheet already decides how that header behaves; pinning
|
|
35
|
+
* the wrapper around it would layer a second, competing rule onto markup this
|
|
36
|
+
* feature does not own.
|
|
37
|
+
*/
|
|
38
|
+
export const resolveSlotSticky = ({
|
|
39
|
+
slug,
|
|
40
|
+
sticky,
|
|
41
|
+
hasComposition,
|
|
42
|
+
themeSettings
|
|
43
|
+
}: {
|
|
44
|
+
slug: string;
|
|
45
|
+
sticky?: SlotStickyConfig;
|
|
46
|
+
hasComposition: boolean;
|
|
47
|
+
themeSettings?: Record<string, unknown> | null;
|
|
48
|
+
}): SlotSticky | null => {
|
|
49
|
+
if (sticky === false) return null;
|
|
50
|
+
if (sticky === true) return { ...SLOT_STICKY_DEFAULTS };
|
|
51
|
+
if (sticky) return { ...SLOT_STICKY_DEFAULTS, ...sticky };
|
|
52
|
+
|
|
53
|
+
if (!hasComposition) return null;
|
|
54
|
+
if (slug !== PLACEHOLDER_SLUGS.header) return null;
|
|
55
|
+
|
|
56
|
+
return themeSettings?.mainMenuSticky ? { ...SLOT_STICKY_DEFAULTS } : null;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The pin goes on the slot element itself, not on anything the composition
|
|
61
|
+
* renders: every box inside the slot hugs the composed header's own height, so
|
|
62
|
+
* a `position: sticky` set in there has no distance left to travel. The slot's
|
|
63
|
+
* parent is the page-height wrapper, which is what makes the pin real.
|
|
64
|
+
*/
|
|
65
|
+
export const slotStickyStyle = (
|
|
66
|
+
sticky: SlotSticky | null
|
|
67
|
+
): CSSProperties | undefined =>
|
|
68
|
+
sticky
|
|
69
|
+
? { position: 'sticky', top: sticky.top, zIndex: sticky.zIndex }
|
|
70
|
+
: undefined;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { getWidgetData } from '@akinon/next/data/server';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The `theme-config` widget, read and parsed once per request.
|
|
6
|
+
*
|
|
7
|
+
* It carries the two things the render engine needs before any section renders
|
|
8
|
+
* — the designer's theme settings and the data sources sections bind to — and
|
|
9
|
+
* every placeholder on the page used to fetch and parse it for itself. Now
|
|
10
|
+
* that a ChromeSlot also has to consult the settings (to know whether it is
|
|
11
|
+
* pinned) that would be another read per slot, so the read lives here and is
|
|
12
|
+
* memoized for the request.
|
|
13
|
+
*/
|
|
14
|
+
export interface ThemeConfig {
|
|
15
|
+
settings: Record<string, unknown> | null;
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
17
|
+
dataSources: any[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type ThemeConfigWidget = {
|
|
21
|
+
attributes?: Record<string, unknown> | null;
|
|
22
|
+
} | null;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Attribute values arrive either bare or wrapped in `{ value }`, depending on
|
|
26
|
+
* which backend and which core version answered.
|
|
27
|
+
*/
|
|
28
|
+
const unwrapAttribute = (raw: unknown): unknown => {
|
|
29
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw) && 'value' in raw) {
|
|
30
|
+
return (raw as { value?: unknown }).value ?? raw;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return raw;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const readThemeConfig = (data: ThemeConfigWidget): ThemeConfig => {
|
|
37
|
+
const config: ThemeConfig = { settings: null, dataSources: [] };
|
|
38
|
+
const attributes = data?.attributes;
|
|
39
|
+
|
|
40
|
+
if (!attributes) return config;
|
|
41
|
+
|
|
42
|
+
if (attributes.theme_dataSources) {
|
|
43
|
+
try {
|
|
44
|
+
const resolved = unwrapAttribute(attributes.theme_dataSources);
|
|
45
|
+
|
|
46
|
+
if (typeof resolved === 'string') {
|
|
47
|
+
config.dataSources = JSON.parse(resolved);
|
|
48
|
+
} else if (Array.isArray(resolved)) {
|
|
49
|
+
config.dataSources = resolved;
|
|
50
|
+
}
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.error('Failed to parse theme_dataSources:', error);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (attributes.theme_settings) {
|
|
57
|
+
try {
|
|
58
|
+
const resolved = unwrapAttribute(attributes.theme_settings);
|
|
59
|
+
|
|
60
|
+
if (typeof resolved === 'string') {
|
|
61
|
+
config.settings = JSON.parse(resolved);
|
|
62
|
+
} else if (resolved && typeof resolved === 'object') {
|
|
63
|
+
config.settings = resolved as Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
} catch (error) {
|
|
66
|
+
console.error('Failed to parse theme_settings:', error);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return config;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const load = async (): Promise<ThemeConfig> => {
|
|
74
|
+
try {
|
|
75
|
+
// The generic is the widget's attributes bag, not the widget wrapper
|
|
76
|
+
const data = await getWidgetData<{
|
|
77
|
+
theme_settings?: unknown;
|
|
78
|
+
theme_dataSources?: unknown;
|
|
79
|
+
}>({ slug: 'theme-config' });
|
|
80
|
+
|
|
81
|
+
return readThemeConfig(data as ThemeConfigWidget);
|
|
82
|
+
} catch {
|
|
83
|
+
// A brand with no published theme still renders: no settings, no sources.
|
|
84
|
+
return { settings: null, dataSources: [] };
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// `React.cache` exists on the RSC-capable React this package renders under;
|
|
89
|
+
// fall back to the raw function on older cores, where correctness is identical
|
|
90
|
+
// and the only cost is a second fetch.
|
|
91
|
+
const cache = (React as { cache?: <T>(fn: T) => T }).cache;
|
|
92
|
+
|
|
93
|
+
export const getThemeConfig = cache ? cache(load) : load;
|
package/src/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ export {
|
|
|
35
35
|
LiveAccountOrderPageContext
|
|
36
36
|
} from './chrome/live-order-page-context';
|
|
37
37
|
export { LiveCheckoutPageContext } from './chrome/live-checkout-page-context';
|
|
38
|
+
export type { SlotSticky, SlotStickyConfig } from './chrome/sticky-slot';
|
|
38
39
|
|
|
39
40
|
export {
|
|
40
41
|
buildProductPageContext,
|
|
@@ -96,23 +96,10 @@ const injectThemeColors = (settings: Record<string, unknown>) => {
|
|
|
96
96
|
root.style.removeProperty('--theme-breadcrumb-icon-display');
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
isSticky ? 'sticky' : 'relative'
|
|
104
|
-
);
|
|
105
|
-
root.style.setProperty('--theme-header-top', isSticky ? '0' : 'auto');
|
|
106
|
-
root.style.setProperty('--theme-header-z-index', isSticky ? '40' : 'auto');
|
|
107
|
-
|
|
108
|
-
// Sticky positioning requires ancestors to not have overflow: hidden
|
|
109
|
-
// We force overflow-x to 'clip' (modern browsers) or 'visible' to fix this
|
|
110
|
-
if (isSticky) {
|
|
111
|
-
document.body.style.overflowX = 'clip';
|
|
112
|
-
} else {
|
|
113
|
-
document.body.style.overflowX = '';
|
|
114
|
-
}
|
|
115
|
-
}
|
|
99
|
+
// `mainMenuSticky` is deliberately not handled here: it used to write three
|
|
100
|
+
// --theme-header-* variables for a brand's own header CSS to read, which
|
|
101
|
+
// nothing ever did, and a composed header is not the brand's markup anyway.
|
|
102
|
+
// It is answered where the pin can actually work — chrome/sticky-slot.ts.
|
|
116
103
|
};
|
|
117
104
|
|
|
118
105
|
/**
|
|
@@ -56,6 +56,7 @@ import { Section } from './theme-section';
|
|
|
56
56
|
import { generateThemeCSS } from './utils';
|
|
57
57
|
import { devWarn } from './utils/dev-warn';
|
|
58
58
|
import { validateThemeSectionsInDev } from './schemas/theme-schema';
|
|
59
|
+
import { getThemeConfig } from './chrome/theme-config-source';
|
|
59
60
|
|
|
60
61
|
type ThemePlaceholderData = {
|
|
61
62
|
theme_editor_placeholder: Array<{
|
|
@@ -74,58 +75,10 @@ export default async function ThemePlaceholder({
|
|
|
74
75
|
pageContext = null
|
|
75
76
|
}: ThemePlaceholderProps) {
|
|
76
77
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
slug: 'theme-config'
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
if (themeConfigData?.attributes?.theme_dataSources) {
|
|
85
|
-
try {
|
|
86
|
-
const raw = themeConfigData.attributes.theme_dataSources;
|
|
87
|
-
// Handle both plain string/array and { value: "..." } attribute format
|
|
88
|
-
const resolved =
|
|
89
|
-
typeof raw === 'string'
|
|
90
|
-
? raw
|
|
91
|
-
: typeof raw === 'object' && raw !== null && !Array.isArray(raw)
|
|
92
|
-
? (raw as { value?: any }).value ?? raw
|
|
93
|
-
: raw;
|
|
94
|
-
|
|
95
|
-
if (typeof resolved === 'string') {
|
|
96
|
-
dataSources = JSON.parse(resolved);
|
|
97
|
-
} else if (Array.isArray(resolved)) {
|
|
98
|
-
dataSources = resolved;
|
|
99
|
-
}
|
|
100
|
-
} catch (e) {
|
|
101
|
-
console.error('Failed to parse theme_dataSources:', e);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
if (themeConfigData?.attributes?.theme_settings) {
|
|
106
|
-
try {
|
|
107
|
-
const rawSettings = themeConfigData.attributes.theme_settings;
|
|
108
|
-
const resolvedValue =
|
|
109
|
-
typeof rawSettings === 'string'
|
|
110
|
-
? rawSettings
|
|
111
|
-
: typeof rawSettings === 'object' && rawSettings !== null
|
|
112
|
-
? (rawSettings as { value?: string }).value ?? rawSettings
|
|
113
|
-
: null;
|
|
114
|
-
|
|
115
|
-
if (resolvedValue) {
|
|
116
|
-
if (typeof resolvedValue === 'string') {
|
|
117
|
-
themeSettings = JSON.parse(resolvedValue);
|
|
118
|
-
} else if (typeof resolvedValue === 'object') {
|
|
119
|
-
themeSettings = resolvedValue as Record<string, unknown>;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
} catch (e) {
|
|
123
|
-
console.error('Failed to parse theme_settings:', e);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
} catch (configError) {
|
|
127
|
-
// theme-config is optional for placeholder rendering
|
|
128
|
-
}
|
|
78
|
+
// theme-config is optional for placeholder rendering, and the reader is
|
|
79
|
+
// memoized per request — a page with several placeholders, or a ChromeSlot
|
|
80
|
+
// asking the same question to decide whether it is pinned, reads it once.
|
|
81
|
+
const { dataSources, settings: themeSettings } = await getThemeConfig();
|
|
129
82
|
|
|
130
83
|
let data: { slug?: string; attributes?: { theme_editor_placeholder?: unknown; [key: string]: unknown } } | null = null;
|
|
131
84
|
try {
|