@akinon/pz-theme 2.0.130-rc.0 → 2.0.130
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 +10 -35
- package/package.json +4 -4
- package/src/chrome/__tests__/theme-config-source.test.ts +73 -8
- package/src/chrome/error-composition-source.ts +5 -1
- package/src/chrome/preview-snapshot-source.ts +2 -0
- package/src/chrome/theme-config-source.ts +31 -2
- package/src/load-placeholder-sections.ts +15 -3
- package/src/overlays/use-overlay-breakpoint.ts +4 -2
- package/src/theme-placeholder-wrapper.tsx +49 -7
- package/src/theme-placeholder.tsx +9 -2
- package/src/utils/__tests__/custom-breakpoints.test.ts +326 -0
- package/src/utils/breakpoints.ts +194 -2
- package/src/utils/index.ts +174 -42
- package/src/utils/property-switches.ts +2 -2
- package/src/utils/section-content-width.ts +2 -2
- package/src/utils/visibility-rules.ts +6 -6
|
@@ -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
|
+
});
|
package/src/utils/breakpoints.ts
CHANGED
|
@@ -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
|
-
|
|
116
|
-
|
|
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
|