@akinon/pz-theme 2.0.130-rc.0 → 2.0.131-beta.0

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.
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Breakpoints the designer adds next to desktop, tablet and mobile.
3
+ *
4
+ * A custom breakpoint is an override layered over the three: inside its range
5
+ * it paints what it sets itself, and everything else it reads from the
6
+ * built-in breakpoint it stands on. The tests hold the three readers to that —
7
+ * the live stylesheet, the canvas and the value readers — and hold a theme
8
+ * without custom breakpoints to the stylesheet it always had.
9
+ */
10
+ import {
11
+ generateThemeCSS,
12
+ getResponsiveValue,
13
+ breakpointForViewport,
14
+ customBreakpointMediaQuery,
15
+ isNarrowBreakpoint,
16
+ readCustomBreakpoints,
17
+ registerCustomBreakpoints,
18
+ type CustomBreakpoint
19
+ } from '../index';
20
+ import { isBreakpointAllowed } from '../visibility-rules';
21
+
22
+ const section = (styles: Record<string, unknown>, id = 's1') => [
23
+ { id, styles } as never
24
+ ];
25
+
26
+ const WIDE: CustomBreakpoint = {
27
+ name: 'custom-wide',
28
+ label: 'Wide desktop',
29
+ width: 1440,
30
+ minWidth: 1440
31
+ };
32
+
33
+ const SMALL_PHONE: CustomBreakpoint = {
34
+ name: 'custom-small',
35
+ label: 'Small phone',
36
+ width: 374,
37
+ maxWidth: 374
38
+ };
39
+
40
+ const LAPTOP: CustomBreakpoint = {
41
+ name: 'custom-laptop',
42
+ label: 'Laptop',
43
+ width: 1024,
44
+ minWidth: 1024,
45
+ maxWidth: 1279
46
+ };
47
+
48
+ const CUSTOM = [WIDE, LAPTOP, SMALL_PHONE];
49
+
50
+ /**
51
+ * What applies at `width` in a stylesheet this generator produced: every rule
52
+ * carries the same selector, so the last matching declaration wins.
53
+ */
54
+ const computedAt = (css: string, width: number): Record<string, string> => {
55
+ const applied: Record<string, string> = {};
56
+
57
+ const queryMatches = (prelude: string): boolean => {
58
+ const max = prelude.match(/max-width:\s*(\d+)px/);
59
+ const min = prelude.match(/min-width:\s*(\d+)px/);
60
+ if (max && width > Number(max[1])) return false;
61
+ if (min && width < Number(min[1])) return false;
62
+ return true;
63
+ };
64
+
65
+ const readDeclarations = (body: string) => {
66
+ body.split(';').forEach((declaration) => {
67
+ const [property, ...rest] = declaration.split(':');
68
+ if (rest.length === 0) return;
69
+ const name = property.trim();
70
+ if (!name || name.startsWith('@')) return;
71
+ applied[name] = rest.join(':').trim();
72
+ });
73
+ };
74
+
75
+ let index = 0;
76
+ while (index < css.length) {
77
+ const open = css.indexOf('{', index);
78
+ if (open === -1) break;
79
+
80
+ const prelude = css.slice(index, open).trim();
81
+ let depth = 0;
82
+ let close = open;
83
+ for (let i = open; i < css.length; i++) {
84
+ if (css[i] === '{') depth += 1;
85
+ else if (css[i] === '}') {
86
+ depth -= 1;
87
+ if (depth === 0) {
88
+ close = i;
89
+ break;
90
+ }
91
+ }
92
+ }
93
+
94
+ const body = css.slice(open + 1, close);
95
+
96
+ if (prelude.startsWith('@media')) {
97
+ if (queryMatches(prelude)) {
98
+ const innerOpen = body.indexOf('{');
99
+ if (innerOpen !== -1) {
100
+ readDeclarations(body.slice(innerOpen + 1, body.indexOf('}')));
101
+ }
102
+ }
103
+ } else if (!prelude.includes(':hover')) {
104
+ readDeclarations(body);
105
+ }
106
+
107
+ index = close + 1;
108
+ }
109
+
110
+ return applied;
111
+ };
112
+
113
+ afterEach(() => registerCustomBreakpoints([]));
114
+
115
+ describe('reading the theme breakpoints', () => {
116
+ it('keeps the custom entries, widest first, and drops what it cannot use', () => {
117
+ expect(
118
+ readCustomBreakpoints([
119
+ { name: 'desktop', width: 1024 },
120
+ { name: 'tablet', width: 768 },
121
+ {
122
+ name: 'custom-small',
123
+ label: 'Small phone',
124
+ width: 374,
125
+ maxWidth: 374
126
+ },
127
+ {
128
+ name: 'custom-wide',
129
+ label: 'Wide desktop',
130
+ width: 1440,
131
+ minWidth: 1440
132
+ },
133
+ { name: 'custom-inverted', minWidth: 900, maxWidth: 600 },
134
+ { name: '', minWidth: 10 },
135
+ null,
136
+ 'custom-string'
137
+ ])
138
+ ).toEqual([WIDE, SMALL_PHONE]);
139
+ });
140
+
141
+ it('reads the old widget builder shape as a range from its width up', () => {
142
+ expect(readCustomBreakpoints([{ name: 'large', width: 1600 }])).toEqual([
143
+ { name: 'large', label: 'large', width: 1600, minWidth: 1600 }
144
+ ]);
145
+ });
146
+
147
+ it('answers empty for anything that is not a list', () => {
148
+ expect(readCustomBreakpoints(undefined)).toEqual([]);
149
+ expect(readCustomBreakpoints({ name: 'custom-wide' })).toEqual([]);
150
+ });
151
+
152
+ it('builds the query from the ends the range has', () => {
153
+ expect(customBreakpointMediaQuery(WIDE)).toBe('@media (min-width: 1440px)');
154
+ expect(customBreakpointMediaQuery(SMALL_PHONE)).toBe(
155
+ '@media (max-width: 374px)'
156
+ );
157
+ expect(customBreakpointMediaQuery(LAPTOP)).toBe(
158
+ '@media (min-width: 1024px) and (max-width: 1279px)'
159
+ );
160
+ });
161
+ });
162
+
163
+ describe('a theme without custom breakpoints', () => {
164
+ const styles = {
165
+ padding: { desktop: '40px', tablet: '24px', mobile: '16px' },
166
+ color: '#111111'
167
+ };
168
+
169
+ it('emits the stylesheet it emitted before', () => {
170
+ expect(generateThemeCSS(section(styles), undefined, [])).toBe(
171
+ generateThemeCSS(section(styles))
172
+ );
173
+ });
174
+
175
+ it('emits the same stylesheet when none of its values address one', () => {
176
+ expect(generateThemeCSS(section(styles), undefined, CUSTOM)).toBe(
177
+ generateThemeCSS(section(styles))
178
+ );
179
+ });
180
+ });
181
+
182
+ describe('the live stylesheet', () => {
183
+ const styles = {
184
+ padding: {
185
+ desktop: '40px',
186
+ mobile: '16px',
187
+ 'custom-wide': '80px',
188
+ 'custom-laptop': '32px',
189
+ 'custom-small': '8px'
190
+ },
191
+ color: { desktop: '#111111', 'custom-wide': '#ff0000' }
192
+ };
193
+ const css = generateThemeCSS(section(styles), undefined, CUSTOM);
194
+
195
+ it('applies each custom value inside its own range only', () => {
196
+ expect(computedAt(css, 1600)).toEqual({
197
+ padding: '80px',
198
+ color: '#ff0000'
199
+ });
200
+ expect(computedAt(css, 1440)).toEqual({
201
+ padding: '80px',
202
+ color: '#ff0000'
203
+ });
204
+ expect(computedAt(css, 1300)).toEqual({
205
+ padding: '40px',
206
+ color: '#111111'
207
+ });
208
+ expect(computedAt(css, 1100)).toEqual({
209
+ padding: '32px',
210
+ color: '#111111'
211
+ });
212
+ expect(computedAt(css, 800)).toEqual({ padding: '16px', color: '#111111' });
213
+ expect(computedAt(css, 390)).toEqual({ padding: '16px', color: '#111111' });
214
+ expect(computedAt(css, 360)).toEqual({ padding: '8px', color: '#111111' });
215
+ });
216
+
217
+ it('writes the custom rules after the built-in ones', () => {
218
+ expect(css.indexOf('@media (min-width: 1440px)')).toBeGreaterThan(
219
+ css.indexOf('@media (max-width: 1023px)')
220
+ );
221
+ expect(css.indexOf('@media (max-width: 374px)')).toBeGreaterThan(
222
+ css.indexOf('@media (max-width: 1023px)')
223
+ );
224
+ });
225
+
226
+ it('ignores the values of a breakpoint the theme no longer defines', () => {
227
+ expect(generateThemeCSS(section(styles), undefined, [WIDE])).not.toContain(
228
+ '374px'
229
+ );
230
+ expect(generateThemeCSS(section(styles))).toBe(
231
+ generateThemeCSS(
232
+ section({
233
+ padding: { desktop: '40px', mobile: '16px' },
234
+ color: { desktop: '#111111' }
235
+ })
236
+ )
237
+ );
238
+ });
239
+
240
+ it('emits a hover authored for a custom breakpoint inside its range', () => {
241
+ const hoverCss = generateThemeCSS(
242
+ [
243
+ {
244
+ id: 's1',
245
+ styles: {},
246
+ blocks: [
247
+ {
248
+ id: 'b1',
249
+ type: 'button',
250
+ styles: {
251
+ 'hover-color': { desktop: '#000000', 'custom-wide': '#00ff00' }
252
+ }
253
+ }
254
+ ]
255
+ } as never
256
+ ],
257
+ undefined,
258
+ CUSTOM
259
+ );
260
+
261
+ const wideRule = hoverCss.slice(
262
+ hoverCss.indexOf('@media (min-width: 1440px)')
263
+ );
264
+ expect(wideRule).toContain(':hover');
265
+ expect(wideRule).toContain('#00ff00');
266
+ });
267
+ });
268
+
269
+ describe('the canvas', () => {
270
+ const styles = {
271
+ padding: { desktop: '40px', mobile: '16px', 'custom-wide': '80px' }
272
+ };
273
+
274
+ it('shows the custom value when the breakpoint sets one', () => {
275
+ expect(
276
+ computedAt(generateThemeCSS(section(styles), 'custom-wide', CUSTOM), 0)
277
+ ).toEqual({ padding: '80px' });
278
+ });
279
+
280
+ it('shows the band the breakpoint stands on when it sets nothing', () => {
281
+ // A 374px phone reads the mobile value, not the desktop one.
282
+ expect(
283
+ computedAt(generateThemeCSS(section(styles), 'custom-small', CUSTOM), 0)
284
+ ).toEqual({ padding: '16px' });
285
+ expect(
286
+ computedAt(generateThemeCSS(section(styles), 'custom-laptop', CUSTOM), 0)
287
+ ).toEqual({ padding: '40px' });
288
+ });
289
+ });
290
+
291
+ describe('the value readers', () => {
292
+ beforeEach(() => registerCustomBreakpoints(CUSTOM));
293
+
294
+ it('resolve a custom breakpoint through the band it stands on', () => {
295
+ const value = { desktop: 4, tablet: 3, mobile: 2, 'custom-wide': 6 };
296
+
297
+ expect(getResponsiveValue(value, 'custom-wide')).toBe(6);
298
+ expect(getResponsiveValue(value, 'custom-laptop')).toBe(4);
299
+ expect(getResponsiveValue(value, 'custom-small')).toBe(2);
300
+ expect(getResponsiveValue({ desktop: 4 }, 'custom-small')).toBe(4);
301
+ });
302
+
303
+ it('find the breakpoint a viewport is read at', () => {
304
+ expect(breakpointForViewport(1600)).toBe('custom-wide');
305
+ expect(breakpointForViewport(1300)).toBe('desktop');
306
+ expect(breakpointForViewport(1100)).toBe('custom-laptop');
307
+ expect(breakpointForViewport(900)).toBe('tablet');
308
+ expect(breakpointForViewport(500)).toBe('mobile');
309
+ expect(breakpointForViewport(374)).toBe('custom-small');
310
+ });
311
+
312
+ it('call a custom breakpoint narrow when its band is', () => {
313
+ expect(isNarrowBreakpoint('custom-small')).toBe(true);
314
+ expect(isNarrowBreakpoint('custom-wide')).toBe(false);
315
+ expect(isNarrowBreakpoint('custom-unknown')).toBe(false);
316
+ });
317
+
318
+ it('keep visibility lists saved before the breakpoint existed working', () => {
319
+ expect(isBreakpointAllowed(['desktop', 'mobile'], 'custom-small')).toBe(
320
+ true
321
+ );
322
+ expect(isBreakpointAllowed(['desktop'], 'custom-small')).toBe(false);
323
+ expect(isBreakpointAllowed(['mobile'], 'custom-wide')).toBe(false);
324
+ expect(isBreakpointAllowed(['custom-wide'], 'custom-wide')).toBe(true);
325
+ });
326
+ });
@@ -65,6 +65,162 @@ export const BREAKPOINT_FALLBACKS: Record<string, BreakpointName[]> = {
65
65
  tablet: ['mobile']
66
66
  };
67
67
 
68
+ /**
69
+ * A breakpoint the theme defines on top of the three above, in the editor's
70
+ * breakpoint panel.
71
+ *
72
+ * `name` is the key its values are stored under inside every per-breakpoint
73
+ * object (`{ desktop, mobile, 'custom-…': … }`). It is generated once by the
74
+ * editor and never changes; what the designer typed is `label`.
75
+ *
76
+ * Its values apply to viewports from `minWidth` to `maxWidth`, both inclusive,
77
+ * either end open. Anything it does not set itself falls back to the built-in
78
+ * breakpoint it stands on — the band its canvas `width` is in — so a custom
79
+ * breakpoint is an override layered over the three, never a replacement for
80
+ * them. The editor's `constants/breakpoints.ts` reads the same entries the
81
+ * same way; `readCustomBreakpoints` below is the renderer's copy of that
82
+ * reading.
83
+ */
84
+ export interface CustomBreakpoint {
85
+ name: string;
86
+ label: string;
87
+ width: number;
88
+ minWidth?: number;
89
+ maxWidth?: number;
90
+ }
91
+
92
+ const isBuiltInName = (name: string): name is BreakpointName =>
93
+ (BREAKPOINTS as string[]).includes(name);
94
+
95
+ const isPixelValue = (value: unknown): value is number =>
96
+ typeof value === 'number' && Number.isFinite(value) && value >= 0;
97
+
98
+ /**
99
+ * The theme's custom breakpoints, from `theme.breakpoints` as the editor saves
100
+ * it (`theme_breakpoints` on the `theme-config` widget). Entries named after a
101
+ * built-in breakpoint only set that breakpoint's canvas width in the editor,
102
+ * and a malformed entry is dropped on its own rather than taking the rest with
103
+ * it. An entry with a width and no range — the old widget builder's shape —
104
+ * applies from its width up.
105
+ */
106
+ export const readCustomBreakpoints = (raw: unknown): CustomBreakpoint[] => {
107
+ if (!Array.isArray(raw)) return [];
108
+
109
+ return raw
110
+ .map((entry): CustomBreakpoint | null => {
111
+ if (!entry || typeof entry !== 'object') return null;
112
+
113
+ const record = entry as Record<string, unknown>;
114
+ const name = typeof record.name === 'string' ? record.name.trim() : '';
115
+ if (!name || isBuiltInName(name)) return null;
116
+
117
+ let minWidth = isPixelValue(record.minWidth)
118
+ ? record.minWidth
119
+ : undefined;
120
+ const maxWidth = isPixelValue(record.maxWidth)
121
+ ? record.maxWidth
122
+ : undefined;
123
+
124
+ if (minWidth === undefined && maxWidth === undefined) {
125
+ if (!isPixelValue(record.width)) return null;
126
+ minWidth = record.width;
127
+ }
128
+
129
+ if (
130
+ minWidth !== undefined &&
131
+ maxWidth !== undefined &&
132
+ minWidth > maxWidth
133
+ ) {
134
+ return null;
135
+ }
136
+
137
+ const label =
138
+ typeof record.label === 'string' && record.label.trim()
139
+ ? record.label.trim()
140
+ : name;
141
+
142
+ return {
143
+ name,
144
+ label,
145
+ // The canvas width, as the editor computes it: the low edge, or the
146
+ // top of a range that is open at the bottom.
147
+ width:
148
+ minWidth !== undefined && minWidth > 0
149
+ ? minWidth
150
+ : maxWidth ?? minWidth ?? 0,
151
+ ...(minWidth !== undefined ? { minWidth } : {}),
152
+ ...(maxWidth !== undefined ? { maxWidth } : {})
153
+ };
154
+ })
155
+ .filter((entry): entry is CustomBreakpoint => entry !== null)
156
+ .sort((a, b) => b.width - a.width);
157
+ };
158
+
159
+ /** The built-in breakpoint a custom one falls back to. */
160
+ export const baseBreakpointOf = (
161
+ breakpoint: CustomBreakpoint
162
+ ): BreakpointName => breakpointForWidth(breakpoint.width);
163
+
164
+ /** The media query a custom breakpoint's rules are emitted inside. */
165
+ export const customBreakpointMediaQuery = (
166
+ breakpoint: CustomBreakpoint
167
+ ): string => {
168
+ const conditions = [
169
+ breakpoint.minWidth !== undefined && breakpoint.minWidth > 0
170
+ ? `(min-width: ${breakpoint.minWidth}px)`
171
+ : '',
172
+ breakpoint.maxWidth !== undefined
173
+ ? `(max-width: ${breakpoint.maxWidth}px)`
174
+ : ''
175
+ ].filter(Boolean);
176
+
177
+ return conditions.length > 0
178
+ ? `@media ${conditions.join(' and ')}`
179
+ : '@media all';
180
+ };
181
+
182
+ /**
183
+ * The custom breakpoints of the theme on this page, for the readers that only
184
+ * ever see a value and a breakpoint name (`getResponsiveValue` and the
185
+ * visibility rules among them).
186
+ *
187
+ * Browser only. A server renders at `desktop` and never resolves a custom
188
+ * key, and a module-level list there would be shared by every request the
189
+ * process serves. The stylesheet generator is handed its list explicitly for
190
+ * the same reason.
191
+ */
192
+ let registeredCustomBreakpoints: CustomBreakpoint[] = [];
193
+
194
+ export const registerCustomBreakpoints = (
195
+ breakpoints: CustomBreakpoint[]
196
+ ): void => {
197
+ if (typeof window === 'undefined') return;
198
+ registeredCustomBreakpoints = breakpoints;
199
+ };
200
+
201
+ export const getRegisteredCustomBreakpoints = (): CustomBreakpoint[] =>
202
+ registeredCustomBreakpoints;
203
+
204
+ /**
205
+ * Where a breakpoint looks when it has nothing authored of its own, before the
206
+ * desktop value every reader ends on. A custom breakpoint looks at the built-in
207
+ * one it stands on, and then wherever that one looks.
208
+ */
209
+ export const fallbackChainOf = (
210
+ breakpoint: string,
211
+ customBreakpoints: CustomBreakpoint[] = registeredCustomBreakpoints
212
+ ): string[] => {
213
+ const builtIn = BREAKPOINT_FALLBACKS[breakpoint];
214
+ if (builtIn) return builtIn;
215
+ if (isBuiltInName(breakpoint)) return [];
216
+
217
+ const custom = customBreakpoints.find((entry) => entry.name === breakpoint);
218
+ if (!custom) return [];
219
+
220
+ const base = baseBreakpointOf(custom);
221
+ return [base, ...(BREAKPOINT_FALLBACKS[base] || [])];
222
+ };
223
+
68
224
  /**
69
225
  * The mobile query, unchanged and deliberately still `max-width: 1023px`
70
226
  * rather than being narrowed to 767. Narrowing it would hand the 768-1023 band
@@ -105,6 +261,23 @@ export const breakpointForWidth = (width: number): BreakpointName => {
105
261
  return 'mobile';
106
262
  };
107
263
 
264
+ /**
265
+ * The breakpoint a viewport is read at once the theme's custom breakpoints are
266
+ * counted: the custom one whose range holds the width, otherwise the built-in
267
+ * band. The editor refuses overlapping ranges, so at most one can match.
268
+ */
269
+ export const breakpointForViewport = (
270
+ width: number,
271
+ customBreakpoints: CustomBreakpoint[] = registeredCustomBreakpoints
272
+ ): string => {
273
+ const custom = customBreakpoints.find(
274
+ (entry) =>
275
+ width >= (entry.minWidth ?? 0) && width <= (entry.maxWidth ?? Infinity)
276
+ );
277
+
278
+ return custom ? custom.name : breakpointForWidth(width);
279
+ };
280
+
108
281
  /**
109
282
  * "Narrower than desktop" — the question the code that predates the tablet was
110
283
  * actually asking whenever it compared against `'mobile'`. Answering it with a
@@ -112,8 +285,27 @@ export const breakpointForWidth = (width: number): BreakpointName => {
112
285
  * behaving at 768-1023 exactly as they did when `'mobile'` was the only other
113
286
  * value there was.
114
287
  */
115
- export const isNarrowBreakpoint = (breakpoint?: string): boolean =>
116
- breakpoint === 'mobile' || breakpoint === 'tablet';
288
+ /**
289
+ * A registered custom breakpoint as the built-in one it stands on, for the
290
+ * readers that only know the three — anything else is returned as it is.
291
+ */
292
+ export const builtInBreakpointOf = (breakpoint: string): string => {
293
+ const custom = registeredCustomBreakpoints.find(
294
+ (entry) => entry.name === breakpoint
295
+ );
296
+ return custom ? baseBreakpointOf(custom) : breakpoint;
297
+ };
298
+
299
+ export const isNarrowBreakpoint = (breakpoint?: string): boolean => {
300
+ if (breakpoint === 'mobile' || breakpoint === 'tablet') return true;
301
+ if (!breakpoint || breakpoint === 'desktop') return false;
302
+
303
+ // A custom breakpoint is as narrow as the band it stands on.
304
+ const custom = registeredCustomBreakpoints.find(
305
+ (entry) => entry.name === breakpoint
306
+ );
307
+ return custom ? baseBreakpointOf(custom) !== 'desktop' : false;
308
+ };
117
309
 
118
310
  /**
119
311
  * The same two bands as media QUERIES, but as bare media CONDITIONS for a