@akinon/pz-theme 2.0.85-rc.0 → 2.0.86-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.
- package/CHANGELOG.md +15 -33
- package/package.json +4 -4
- package/src/blocks/__tests__/image-block.test.tsx +120 -0
- package/src/blocks/image-block.tsx +53 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,38 +1,20 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
- Updated dependencies [
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
- Updated dependencies [bfafa3f49]
|
|
19
|
-
- Updated dependencies [57d7eb305]
|
|
20
|
-
- Updated dependencies [d99a6a7d5]
|
|
21
|
-
- Updated dependencies [9db81a714]
|
|
22
|
-
- Updated dependencies [591e345e1]
|
|
23
|
-
- Updated dependencies [4de5303c5]
|
|
24
|
-
- Updated dependencies [95b139dc1]
|
|
25
|
-
- Updated dependencies [1d00f2d06]
|
|
26
|
-
- Updated dependencies [4ac7b2a1e]
|
|
27
|
-
- Updated dependencies [e9598c71d]
|
|
28
|
-
- Updated dependencies [4998a9631]
|
|
29
|
-
- Updated dependencies [804d2bd6c]
|
|
30
|
-
- Updated dependencies [3909d3224]
|
|
31
|
-
- Updated dependencies [6a3d8a631]
|
|
32
|
-
- Updated dependencies [e18836b20]
|
|
33
|
-
- Updated dependencies [b1111d7b]
|
|
34
|
-
- @akinon/next@2.0.85-rc.0
|
|
35
|
-
- @akinon/theme-contract@2.0.85-rc.0
|
|
3
|
+
## 2.0.86-beta.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [cbbbfd757]
|
|
8
|
+
- @akinon/next@2.0.86-beta.0
|
|
9
|
+
- @akinon/theme-contract@2.0.86-beta.0
|
|
10
|
+
|
|
11
|
+
## 2.0.85
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- a5787dcf: ZERO-4848: Refactor ImageBlock to improve box sizing and CSS generation logic
|
|
16
|
+
- @akinon/next@2.0.85
|
|
17
|
+
- @akinon/theme-contract@2.0.85
|
|
36
18
|
|
|
37
19
|
## 2.0.84
|
|
38
20
|
|
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.
|
|
4
|
+
"version": "2.0.86-beta.0",
|
|
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.
|
|
11
|
+
"@akinon/next": "2.0.86-beta.0",
|
|
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.
|
|
16
|
+
"@akinon/theme-contract": "2.0.86-beta.0",
|
|
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.
|
|
23
|
+
"@akinon/next": "2.0.86-beta.0",
|
|
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",
|
|
@@ -2,6 +2,7 @@ import React from 'react';
|
|
|
2
2
|
import { render, screen } from '@testing-library/react';
|
|
3
3
|
|
|
4
4
|
import ImageBlock from '../image-block';
|
|
5
|
+
import { generateThemeCSS } from '../../utils';
|
|
5
6
|
import type { BlockRendererProps } from '../block-renderer-registry';
|
|
6
7
|
|
|
7
8
|
// ImageBlock renders a native <img> / <div> directly (no child ThemeBlock /
|
|
@@ -137,3 +138,122 @@ describe('ImageBlock - loading strategy', () => {
|
|
|
137
138
|
expect(getImg(container)?.getAttribute('loading')).toBe('eager');
|
|
138
139
|
});
|
|
139
140
|
});
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The box is declared once.
|
|
144
|
+
*
|
|
145
|
+
* `width`, `height` and `aspect-ratio` are ordinary root style keys, so the
|
|
146
|
+
* generated stylesheet writes them onto this block's wrapper — the element the
|
|
147
|
+
* parent lays out. The renderer inlined the same numbers on the picture as
|
|
148
|
+
* well, which is one authored value applied to two elements that do not have to
|
|
149
|
+
* agree: a wrapper carrying `flex: 1 1 0` is resized by the flex algorithm, an
|
|
150
|
+
* inline `height: 160px` on an <img> is not.
|
|
151
|
+
*
|
|
152
|
+
* On a collection card that is exactly what happened. The card was a 162px
|
|
153
|
+
* square whose caption took 33px, leaving the image block a 127px box — and the
|
|
154
|
+
* picture, carrying the mobile breakpoint's authored `160px`, was 33px taller
|
|
155
|
+
* than the box it sat in. The card's `overflow: hidden` cut the difference off,
|
|
156
|
+
* so on a phone every image was cropped.
|
|
157
|
+
*/
|
|
158
|
+
describe('ImageBlock leaves its box to the sheet', () => {
|
|
159
|
+
const sized = (overrides: Record<string, unknown> = {}) => ({
|
|
160
|
+
id: 'box',
|
|
161
|
+
value: 'https://example.com/a.jpg',
|
|
162
|
+
styles: {
|
|
163
|
+
width: { desktop: '100%' },
|
|
164
|
+
height: { desktop: '100%', mobile: '160px' },
|
|
165
|
+
'object-fit': { desktop: 'contain', mobile: 'cover' },
|
|
166
|
+
'border-radius': { desktop: '8px' }
|
|
167
|
+
},
|
|
168
|
+
...overrides
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('fills the box rather than repeating the authored height', () => {
|
|
172
|
+
const { container } = renderBlock(sized(), 'mobile');
|
|
173
|
+
const img = getImg(container) as HTMLImageElement;
|
|
174
|
+
|
|
175
|
+
// The authored 160px is the WRAPPER's; repeating it here is what overflowed
|
|
176
|
+
// a box the layout had already made shorter.
|
|
177
|
+
expect(img.style.height).toBe('100%');
|
|
178
|
+
expect(img.style.width).toBe('100%');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('does not drop the authored value — it moves to the wrapper rule', () => {
|
|
182
|
+
// The other half of "declared once": the number still reaches the page.
|
|
183
|
+
const css = generateThemeCSS([
|
|
184
|
+
{
|
|
185
|
+
id: 'sec-1',
|
|
186
|
+
styles: {},
|
|
187
|
+
blocks: [sized()]
|
|
188
|
+
}
|
|
189
|
+
] as unknown as Parameters<typeof generateThemeCSS>[0]);
|
|
190
|
+
|
|
191
|
+
expect(css).toContain('[data-block-id="box"]');
|
|
192
|
+
expect(css).toContain('height: 160px');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('still paints what a rule on the wrapper cannot say', () => {
|
|
196
|
+
// `object-fit` is meaningless on a <div>, and the radius has to be on the
|
|
197
|
+
// painted element because the wrapper does not clip.
|
|
198
|
+
const { container } = renderBlock(sized(), 'mobile');
|
|
199
|
+
const img = getImg(container) as HTMLImageElement;
|
|
200
|
+
|
|
201
|
+
expect(img.style.objectFit).toBe('cover');
|
|
202
|
+
expect(img.style.borderRadius).toBe('8px');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('leaves an unsized picture on its intrinsic height', () => {
|
|
206
|
+
// The narrow half of the fix: with nothing authored, the wrapper rule sizes
|
|
207
|
+
// nothing, so filling it would let a stretched flex row crop the picture
|
|
208
|
+
// under the default `object-fit: cover`. Unchanged behaviour on purpose.
|
|
209
|
+
const { container } = renderBlock({
|
|
210
|
+
id: 'unsized',
|
|
211
|
+
value: 'https://example.com/a.jpg',
|
|
212
|
+
styles: { 'object-fit': { desktop: 'cover' } }
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect((getImg(container) as HTMLImageElement).style.height).toBe('auto');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('fills a box the sheet sized with an aspect ratio alone', () => {
|
|
219
|
+
// An aspect ratio sizes the wrapper without naming a height, and it is on
|
|
220
|
+
// the wrapper too — so the picture has to fill, or it would sit at its
|
|
221
|
+
// intrinsic height inside a box shaped for it.
|
|
222
|
+
const { container } = renderBlock({
|
|
223
|
+
id: 'ratio',
|
|
224
|
+
value: 'https://example.com/a.jpg',
|
|
225
|
+
styles: { 'aspect-ratio': { desktop: '1/1' } }
|
|
226
|
+
});
|
|
227
|
+
const img = getImg(container) as HTMLImageElement;
|
|
228
|
+
|
|
229
|
+
expect(img.style.height).toBe('100%');
|
|
230
|
+
expect(img.style.aspectRatio).toBe('');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('inlines a size the row resolved for itself', () => {
|
|
234
|
+
// One rule serves every clone, so a per-row value has nowhere else to live.
|
|
235
|
+
const { container } = renderBlock(
|
|
236
|
+
sized({ rowStyleKeys: ['height'] }),
|
|
237
|
+
'mobile'
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
expect((getImg(container) as HTMLImageElement).style.height).toBe('160px');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('gives an empty block a floor only when nothing sized it', () => {
|
|
244
|
+
const unsized = renderBlock({ id: 'empty', value: '' });
|
|
245
|
+
const placeholder = screen.getByText('No image uploaded');
|
|
246
|
+
|
|
247
|
+
expect(placeholder.style.minHeight).toBe('150px');
|
|
248
|
+
unsized.unmount();
|
|
249
|
+
|
|
250
|
+
// Sized by the wrapper, so filling it is already the right height; the
|
|
251
|
+
// repeated minimum is what pushed the placeholder out of a shorter box.
|
|
252
|
+
renderBlock(
|
|
253
|
+
{ id: 'empty-sized', value: '', styles: { height: { mobile: '160px' } } },
|
|
254
|
+
'mobile'
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
expect(screen.getByText('No image uploaded').style.minHeight).toBe('');
|
|
258
|
+
});
|
|
259
|
+
});
|
|
@@ -123,11 +123,50 @@ const ImageBlock = ({
|
|
|
123
123
|
undefined
|
|
124
124
|
) as string | undefined;
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* THE PICTURE FILLS ITS BOX; IT DOES NOT RE-DECLARE IT.
|
|
128
|
+
*
|
|
129
|
+
* `width`, `height` and `aspect-ratio` are ordinary root style keys, so the
|
|
130
|
+
* generated stylesheet already writes them onto this block's WRAPPER — the
|
|
131
|
+
* element the parent lays out (`image` is not one of
|
|
132
|
+
* INNER_ROOT_BLOCK_TYPES, so the rule lands there). Inlining the same
|
|
133
|
+
* numbers here applied one authored value to two elements, and the two do
|
|
134
|
+
* not have to agree: a wrapper carrying `flex: 1 1 0` is resized by the flex
|
|
135
|
+
* algorithm, an inline `height` on this element is not. A card whose caption
|
|
136
|
+
* left 127px of a 162px square therefore drew a 160px picture inside a 127px
|
|
137
|
+
* box, and the card's `overflow: hidden` ate the rest.
|
|
138
|
+
*
|
|
139
|
+
* The exception is a value the row resolved for itself: one rule serves every
|
|
140
|
+
* clone, so a per-row size has nowhere to live but here (see
|
|
141
|
+
* `collectRowBoundStyleKeys`).
|
|
142
|
+
*/
|
|
143
|
+
const isRowBound = (key: string): boolean =>
|
|
144
|
+
(block.rowStyleKeys ?? []).includes(key);
|
|
145
|
+
/**
|
|
146
|
+
* Whether the wrapper rule gives the box a height at all. Deliberately narrow:
|
|
147
|
+
* where nothing was authored the picture keeps its intrinsic `auto` height, so
|
|
148
|
+
* an unsized image in a stretched flex row renders exactly as it does today
|
|
149
|
+
* rather than filling the stretch and being cropped by the default
|
|
150
|
+
* `object-fit: cover` — a regression the fix must not trade for the one it
|
|
151
|
+
* repairs.
|
|
152
|
+
*/
|
|
153
|
+
const boxIsSized =
|
|
154
|
+
(typeof height === 'string' && height !== 'auto') || Boolean(aspectRatio);
|
|
155
|
+
const boxWidth = isRowBound('width') ? width ?? '100%' : '100%';
|
|
156
|
+
const boxHeight = isRowBound('height')
|
|
157
|
+
? height ?? 'auto'
|
|
158
|
+
: boxIsSized
|
|
159
|
+
? '100%'
|
|
160
|
+
: 'auto';
|
|
161
|
+
const boxAspectRatio = isRowBound('aspect-ratio')
|
|
162
|
+
? aspectRatio || undefined
|
|
163
|
+
: undefined;
|
|
164
|
+
|
|
126
165
|
const content = !src ? (
|
|
127
166
|
<div
|
|
128
167
|
style={{
|
|
129
|
-
width:
|
|
130
|
-
height:
|
|
168
|
+
width: boxWidth,
|
|
169
|
+
height: boxHeight,
|
|
131
170
|
borderRadius: borderRadius ?? '0px',
|
|
132
171
|
overflow: 'hidden',
|
|
133
172
|
backgroundColor: '#e5e7eb',
|
|
@@ -135,9 +174,12 @@ const ImageBlock = ({
|
|
|
135
174
|
alignItems: 'center',
|
|
136
175
|
justifyContent: 'center',
|
|
137
176
|
textAlign: 'center',
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
177
|
+
// A floor only when nothing sized the box. An authored height is on the
|
|
178
|
+
// wrapper, so filling it is already the right size — repeating the
|
|
179
|
+
// number as a minimum is what pushed the placeholder out of a box the
|
|
180
|
+
// layout had made shorter.
|
|
181
|
+
minHeight: boxIsSized ? undefined : '150px',
|
|
182
|
+
aspectRatio: boxAspectRatio,
|
|
141
183
|
color: '#6b7280'
|
|
142
184
|
}}
|
|
143
185
|
>
|
|
@@ -152,11 +194,14 @@ const ImageBlock = ({
|
|
|
152
194
|
fetchPriority={priority}
|
|
153
195
|
style={{
|
|
154
196
|
display: 'block',
|
|
155
|
-
width:
|
|
156
|
-
height:
|
|
197
|
+
width: boxWidth,
|
|
198
|
+
height: boxHeight,
|
|
199
|
+
// These two stay: `object-fit` is meaningless on the wrapper <div>, and
|
|
200
|
+
// the radius has to be on the painted element because the wrapper does
|
|
201
|
+
// not clip.
|
|
157
202
|
objectFit: objectFit ?? 'cover',
|
|
158
203
|
borderRadius: borderRadius ?? '0px',
|
|
159
|
-
aspectRatio:
|
|
204
|
+
aspectRatio: boxAspectRatio
|
|
160
205
|
}}
|
|
161
206
|
/>
|
|
162
207
|
);
|