@akinon/pz-theme 2.0.60-rc.0 → 2.0.60

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 CHANGED
@@ -1,36 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.60-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [0cf9ea239]
8
- - Updated dependencies [324f97d55]
9
- - Updated dependencies [51ea06888]
10
- - Updated dependencies
11
- - Updated dependencies [2fea4143]
12
- - Updated dependencies [b55acb768]
13
- - Updated dependencies [760258c1c]
14
- - Updated dependencies [143be2b9d]
15
- - Updated dependencies [7889b08fe]
16
- - Updated dependencies [9f8cd3bc5]
17
- - Updated dependencies [bfafa3f49]
18
- - Updated dependencies [57d7eb305]
19
- - Updated dependencies [d99a6a7d5]
20
- - Updated dependencies [9db81a714]
21
- - Updated dependencies [591e345e1]
22
- - Updated dependencies [4de5303c5]
23
- - Updated dependencies [95b139dc1]
24
- - Updated dependencies [1d00f2d06]
25
- - Updated dependencies [4ac7b2a1e]
26
- - Updated dependencies [e9598c71]
27
- - Updated dependencies [4998a9631]
28
- - Updated dependencies [804d2bd6]
29
- - Updated dependencies [3909d3224]
30
- - Updated dependencies [6a3d8a63]
31
- - Updated dependencies [e18836b20]
32
- - @akinon/next@2.0.60-rc.0
33
- - @akinon/theme-contract@2.0.60-rc.0
3
+ ## 2.0.60
4
+
5
+ ### Patch Changes
6
+
7
+ - 29b039a: ZERO-4809: Publish an overlay-open page state and close the panel in the canvas
8
+ - @akinon/next@2.0.60
9
+ - @akinon/theme-contract@2.0.60
34
10
 
35
11
  ## 2.0.59
36
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.60-rc.0",
4
+ "version": "2.0.60",
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.60-rc.0",
11
+ "@akinon/next": "2.0.60",
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.60-rc.0",
16
+ "@akinon/theme-contract": "2.0.60",
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.60-rc.0",
23
+ "@akinon/next": "2.0.60",
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",
@@ -90,6 +90,12 @@ describe('the legacy size-guide markup, byte for byte', () => {
90
90
  // generalised. Every one of these is markup a live storefront is already
91
91
  // styling, so nothing here may move: the centred modal keeps its combined
92
92
  // root/backdrop node, its attribute order and its missing `tabIndex`.
93
+ //
94
+ // `legacy-designer` is the exception, and only because no storefront ever
95
+ // renders it — `isDesigner` is false everywhere outside the editor canvas.
96
+ // It was re-captured when the canvas stopped rendering a closed panel's
97
+ // children inline: that copy pushed the composition around the panel out of
98
+ // place, and "Hold open in canvas" is the control that shows it open.
93
99
  const CASES: Array<[string, Record<string, unknown>, boolean, boolean]> = [
94
100
  ['legacy-closed', {}, false, false],
95
101
  ['legacy-open', {}, false, true],
@@ -341,3 +347,43 @@ describe('an empty panel', () => {
341
347
  expect(placeholder?.textContent).not.toMatch(/size/i);
342
348
  });
343
349
  });
350
+
351
+ describe('what the canvas shows while the panel is closed', () => {
352
+ it('renders the trigger only, so the composition around it keeps its layout', () => {
353
+ // The defect: a panel dropped into a header rendered its whole menu inline,
354
+ // under the trigger, and shoved the logo and the icons out of place — while
355
+ // the author was editing exactly that header. It also showed something no
356
+ // shopper ever sees.
357
+ const { container } = renderPanel({}, { isDesigner: true });
358
+
359
+ expect(container.querySelector('[data-size-guide-trigger]')).toBeTruthy();
360
+ expect(container.querySelector('[data-id="child-1"]')).toBeNull();
361
+ expect(
362
+ container.querySelector('[data-size-guide]')?.getAttribute(
363
+ 'data-size-guide-state'
364
+ )
365
+ ).toBe('closed');
366
+ });
367
+
368
+ it('shows the open panel once it is held open, children and all', () => {
369
+ // "Hold open in canvas" is the control that answers "let me see it open",
370
+ // and it draws the real overlay rather than an inline copy of it.
371
+ const { container } = renderPanel(
372
+ {},
373
+ { isDesigner: true, styles: { forceOpen: { desktop: true } } }
374
+ );
375
+
376
+ expect(container.querySelector('[data-id="child-1"]')).toBeTruthy();
377
+ expect(container.querySelector('[data-part="modal"]')).toBeTruthy();
378
+ });
379
+
380
+ it('keeps the empty-state hint, which has no composition to disturb', () => {
381
+ // Without it a panel just added from the block list looks like nothing
382
+ // happened; with no children there is nothing to push out of place.
383
+ const { container } = renderPanel({}, { isDesigner: true, blocks: [] });
384
+
385
+ expect(container.textContent).toContain(
386
+ 'Panel (drop the blocks it opens here)'
387
+ );
388
+ });
389
+ });
@@ -3,5 +3,5 @@
3
3
  "legacy-open": "<div data-size-guide=\"\" data-size-guide-state=\"open\"><button type=\"button\" data-size-guide-trigger=\"\" data-part=\"trigger\" aria-haspopup=\"dialog\" aria-expanded=\"true\" style=\"display: inline-flex; align-items: center; gap: 6px; padding: 0px; margin: 0px; background: transparent; text-decoration: underline; cursor: pointer;\" data-state=\"open\">Size guide</button><div data-size-guide-overlay=\"\" data-part=\"overlay\" data-state=\"open\" style=\"position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 16px; background: rgba(0, 0, 0, 0.5);\"><div data-size-guide-content=\"\" data-size-guide-mode=\"modal\" data-part=\"modal\" data-state=\"open\" role=\"dialog\" aria-modal=\"true\" aria-label=\"Size guide\" style=\"position: relative; width: 100%; max-width: 640px; max-height: 80vh; overflow: auto; padding: 24px; border-radius: 4px; background: rgb(255, 255, 255); color: rgb(17, 24, 39); text-align: left;\"><button type=\"button\" data-size-guide-close=\"\" data-part=\"close\" aria-label=\"Close\" style=\"position: absolute; top: 12px; right: 12px; background: transparent; cursor: pointer; padding: 0px;\"><svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\"><path d=\"M6 6l12 12M18 6L6 18\"></path></svg></button><div data-testid=\"tb\" data-id=\"child-1\"></div></div></div></div>",
4
4
  "legacy-open-titled": "<div data-size-guide=\"\" data-size-guide-state=\"open\"><button type=\"button\" data-size-guide-trigger=\"\" data-part=\"trigger\" aria-haspopup=\"dialog\" aria-expanded=\"true\" style=\"display: inline-flex; align-items: center; gap: 6px; padding: 0px; margin: 0px; background: transparent; text-decoration: underline; cursor: pointer;\" data-state=\"open\">Size guide</button><div data-size-guide-overlay=\"\" data-part=\"overlay\" data-state=\"open\" style=\"position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 16px; background: rgba(0, 0, 0, 0.5);\"><div data-size-guide-content=\"\" data-size-guide-mode=\"modal\" data-part=\"modal\" data-state=\"open\" role=\"dialog\" aria-modal=\"true\" aria-label=\"Size chart\" style=\"position: relative; width: 100%; max-width: 900px; max-height: 80vh; overflow: auto; padding: 24px; border-radius: 4px; background: rgb(255, 255, 255); color: rgb(17, 24, 39); text-align: left;\"><button type=\"button\" data-size-guide-close=\"\" data-part=\"close\" aria-label=\"Close\" style=\"position: absolute; top: 12px; right: 12px; background: transparent; cursor: pointer; padding: 0px;\"><svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\"><path d=\"M6 6l12 12M18 6L6 18\"></path></svg></button><div data-size-guide-title=\"\" data-part=\"title\" style=\"margin-bottom: 12px; font-weight: 600;\">Size chart</div><div data-testid=\"tb\" data-id=\"child-1\"></div></div></div></div>",
5
5
  "legacy-open-noclose": "<div data-size-guide=\"\" data-size-guide-state=\"open\"><button type=\"button\" data-size-guide-trigger=\"\" data-part=\"trigger\" aria-haspopup=\"dialog\" aria-expanded=\"true\" style=\"display: inline-flex; align-items: center; gap: 6px; padding: 0px; margin: 0px; background: transparent; text-decoration: underline; cursor: pointer;\" data-state=\"open\">Size guide</button><div data-size-guide-overlay=\"\" data-part=\"overlay\" data-state=\"open\" style=\"position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 16px; background: rgba(0, 0, 0, 0.5);\"><div data-size-guide-content=\"\" data-size-guide-mode=\"modal\" data-part=\"modal\" data-state=\"open\" role=\"dialog\" aria-modal=\"true\" aria-label=\"Size guide\" style=\"position: relative; width: 100%; max-width: 640px; max-height: 80vh; overflow: auto; padding: 24px; border-radius: 4px; background: rgb(255, 255, 255); color: rgb(17, 24, 39); text-align: left;\"><div data-testid=\"tb\" data-id=\"child-1\"></div></div></div></div>",
6
- "legacy-designer": "<div data-size-guide=\"\" data-size-guide-state=\"inline\"><button type=\"button\" data-size-guide-trigger=\"\" data-part=\"trigger\" aria-haspopup=\"dialog\" aria-expanded=\"false\" style=\"display: inline-flex; align-items: center; gap: 6px; padding: 0px; margin: 0px; background: transparent; text-decoration: underline; cursor: pointer;\">Size guide</button><div data-size-guide-content=\"\" data-size-guide-mode=\"inline\" data-part=\"modal\" style=\"margin-top: 8px; padding: 12px; border: 1px dashed #cbd5e1; border-radius: 4px;\"><div data-testid=\"tb\" data-id=\"child-1\"></div></div></div>"
6
+ "legacy-designer": "<div data-size-guide=\"\" data-size-guide-state=\"closed\"><button type=\"button\" data-size-guide-trigger=\"\" data-part=\"trigger\" aria-haspopup=\"dialog\" aria-expanded=\"false\" style=\"display: inline-flex; align-items: center; gap: 6px; padding: 0px; margin: 0px; background: transparent; text-decoration: underline; cursor: pointer;\">Size guide</button></div>"
7
7
  }
@@ -272,29 +272,43 @@ const PanelBlock = ({
272
272
  ) : null;
273
273
 
274
274
  if (isDesigner && !forcedOpen) {
275
+ /**
276
+ * Closed in the canvas, exactly as it is on the storefront.
277
+ *
278
+ * The children used to render INLINE here, in flow under the trigger, so
279
+ * they stayed selectable without a click the author cannot perform inside
280
+ * the preview iframe. That predates the force-open toggle, and once the
281
+ * toggle existed the inline copy became the worse of the two: it is why a
282
+ * panel sitting in a header shoves the logo and the icons out of place
283
+ * while that header is being composed, and what the author sees there is
284
+ * not what a shopper gets.
285
+ *
286
+ * "Hold open in canvas" is the answer to seeing it open, and it draws the
287
+ * REAL overlay — right geometry, dressable backdrop. With the panel closed
288
+ * the layer tree still reaches every child.
289
+ *
290
+ * The empty state is the one thing that stays: a panel just added from the
291
+ * block list would otherwise look like nothing happened, and an empty
292
+ * panel has no content to push anything out of place.
293
+ */
275
294
  return (
276
- <div data-size-guide="" data-size-guide-state="inline">
295
+ <div data-size-guide="" data-size-guide-state="closed">
277
296
  {trigger}
278
- <div
279
- data-size-guide-content=""
280
- data-size-guide-mode="inline"
281
- // The same part as the storefront panel: the author has to see the
282
- // panel styling they just typed, and the panel never opens here.
283
- {...partAttrs('modal')}
284
- style={{
285
- marginTop: '8px',
286
- padding: '12px',
287
- border: '1px dashed #cbd5e1',
288
- borderRadius: '4px'
289
- }}
290
- >
291
- {titleNode}
292
- {childBlocks.length > 0 ? (
293
- renderChildren()
294
- ) : (
297
+ {childBlocks.length === 0 ? (
298
+ <div
299
+ data-size-guide-content=""
300
+ data-size-guide-mode="empty"
301
+ {...partAttrs('modal')}
302
+ style={{
303
+ marginTop: '8px',
304
+ padding: '12px',
305
+ border: '1px dashed #cbd5e1',
306
+ borderRadius: '4px'
307
+ }}
308
+ >
295
309
  <DesignerPlaceholder label="Panel (drop the blocks it opens here)" />
296
- )}
297
- </div>
310
+ </div>
311
+ ) : null}
298
312
  </div>
299
313
  );
300
314
  }
@@ -157,6 +157,7 @@ function Page({ a, b }: PageProps) {
157
157
  describe('useOverlay', () => {
158
158
  afterEach(() => {
159
159
  document.body.style.overflow = '';
160
+ document.body.removeAttribute('data-overlay-state');
160
161
  });
161
162
 
162
163
  it('reproduces the loop the per-block traps have: two document-level traps pull focus at each other forever', () => {
@@ -487,6 +488,31 @@ describe('useOverlay', () => {
487
488
  expect(closeA).not.toHaveBeenCalled();
488
489
  });
489
490
 
491
+ it('publishes an overlay-open state on the body, and withdraws it only when the last overlay closes', () => {
492
+ // The styling hook a composition needs and cannot derive: a header that
493
+ // has to repaint while its own menu panel is open is not inside that
494
+ // panel, so no part state on the panel can reach it. `data-overlay-state`
495
+ // is the page saying so, in the same shape as the chrome slot's
496
+ // `data-scroll-state`.
497
+ const { rerender } = render(<Page a={{ open: false }} b={{ open: false }} />);
498
+ expect(document.body.hasAttribute('data-overlay-state')).toBe(false);
499
+
500
+ rerender(<Page a={{ open: true }} b={{ open: false }} />);
501
+ expect(document.body.getAttribute('data-overlay-state')).toBe('open');
502
+
503
+ // Two open, then one closes: the page is still held, so the attribute has
504
+ // to stay — the scroll lock learned this the hard way and the attribute is
505
+ // driven off the same stack for the same reason.
506
+ rerender(<Page a={{ open: true }} b={{ open: true }} />);
507
+ expect(document.body.getAttribute('data-overlay-state')).toBe('open');
508
+
509
+ rerender(<Page a={{ open: true }} b={{ open: false }} />);
510
+ expect(document.body.getAttribute('data-overlay-state')).toBe('open');
511
+
512
+ rerender(<Page a={{ open: false }} b={{ open: false }} />);
513
+ expect(document.body.hasAttribute('data-overlay-state')).toBe(false);
514
+ });
515
+
490
516
  it('does not touch the document while rendering on the server', () => {
491
517
  const addEventListener = jest.spyOn(document, 'addEventListener');
492
518
  document.body.style.overflow = 'auto';
@@ -51,7 +51,37 @@ import { useEffect, useId, useMemo, useRef, useSyncExternalStore } from 'react';
51
51
  const overlayStack: string[] = [];
52
52
  const stackListeners = new Set<() => void>();
53
53
 
54
+ /**
55
+ * The attribute a composition styles against while any overlay holds the page.
56
+ *
57
+ * Same shape and same reason as `data-scroll-state` on the chrome slot: a fact
58
+ * about the PAGE that an element cannot learn about itself. The header that
59
+ * turns opaque while its own menu panel is open is the case that forced it —
60
+ * the header is not inside the panel, so no part state on the panel can reach
61
+ * it, and the alternative was every block growing its own listener.
62
+ *
63
+ * It goes on `body` rather than on the chrome slot because an overlay can be
64
+ * opened from anywhere — a page body, a footer, another overlay — and
65
+ * everything that might want to react is a descendant of `body`.
66
+ */
67
+ export const OVERLAY_STATE_ATTRIBUTE = 'data-overlay-state';
68
+ export const OVERLAY_STYLE_STATE = 'open';
69
+
70
+ const syncOverlayStateAttribute = () => {
71
+ if (typeof document === 'undefined') return;
72
+
73
+ // Driven off the stack rather than off each open/close call: two overlays
74
+ // closing in either order must leave exactly one attribute state behind, and
75
+ // the stack is already the thing that knows whether anything is left.
76
+ if (overlayStack.length > 0) {
77
+ document.body.setAttribute(OVERLAY_STATE_ATTRIBUTE, OVERLAY_STYLE_STATE);
78
+ } else {
79
+ document.body.removeAttribute(OVERLAY_STATE_ATTRIBUTE);
80
+ }
81
+ };
82
+
54
83
  const notifyStack = () => {
84
+ syncOverlayStateAttribute();
55
85
  stackListeners.forEach((listener) => listener());
56
86
  };
57
87
 
@@ -204,3 +204,54 @@ describe('checkout safety', () => {
204
204
  ).toEqual({ ':scrolled/background-color': '#ffffff' });
205
205
  });
206
206
  });
207
+
208
+ describe('the overlay-open state', () => {
209
+ const OPEN = '[data-overlay-state~="open"]';
210
+
211
+ it('conditions a part on the page, exactly as scrolled does', () => {
212
+ const [selector] = buildPartSelectors('[data-block-id="blk-1"]', {
213
+ part: 'trigger',
214
+ state: 'overlay-open',
215
+ styles: {}
216
+ });
217
+
218
+ expect(selector).toBe(
219
+ `${OPEN} [data-block-id="blk-1"] [data-part="trigger"]` +
220
+ ':not(:where([data-block-id="blk-1"] [data-block-id] *))'
221
+ );
222
+ });
223
+
224
+ it('repaints a whole header band while a panel is open', () => {
225
+ // The case it exists for: the header is not inside the panel, so the panel
226
+ // cannot style it, and the alternative was a second copy of the logo and
227
+ // the icons authored inside the panel.
228
+ const css = generateThemeCSS([
229
+ section('sec-1', {}, [
230
+ block('bar', {
231
+ 'background-color': 'transparent',
232
+ ':overlay-open/background-color': '#ffffff'
233
+ })
234
+ ])
235
+ ]);
236
+
237
+ expect(css).toContain(
238
+ `${OPEN} [data-block-id="bar"] {
239
+ background-color: rgba(255, 255, 255, 1);
240
+ }`
241
+ );
242
+ });
243
+
244
+ it('matches nothing while every overlay is closed', () => {
245
+ const [selector] = buildPartSelectors('[data-block-id="blk-1"]', {
246
+ part: 'trigger',
247
+ state: 'overlay-open',
248
+ styles: {}
249
+ });
250
+
251
+ document.body.removeAttribute('data-overlay-state');
252
+ document.body.innerHTML =
253
+ '<div data-block-id="blk-1"><button data-part="trigger"></button></div>';
254
+
255
+ expect(document.querySelectorAll(selector)).toHaveLength(0);
256
+ });
257
+ });
@@ -69,13 +69,19 @@ const STATE_SELECTORS: Record<string, string[]> = {
69
69
  available: ['[data-state~="available"]'],
70
70
  unavailable: ['[data-state~="unavailable"]'],
71
71
  unknown: ['[data-state~="unknown"]'],
72
- // The odd one out: every state above is a fact about the ELEMENT and matches
73
- // on the element itself. This one is a fact about the PAGE — the chrome slot
74
- // carries `data-scroll-state` (see chrome/scroll-state.tsx) — so it is
75
- // matched through an ancestor and no renderer has to stamp anything for it to
72
+ // The odd ones out: every state above is a fact about the ELEMENT and matches
73
+ // on the element itself. These two are facts about the PAGE — the chrome slot
74
+ // carries `data-scroll-state` (see chrome/scroll-state.tsx) and `body` carries
75
+ // `data-overlay-state` (see hooks/use-overlay.ts) — so they are matched
76
+ // through an ancestor and no renderer has to stamp anything for them to
76
77
  // apply. That is what lets a header become opaque on scroll, which nothing in
77
78
  // a composition could previously say.
78
- scrolled: ['[data-scroll-state~="scrolled"] &']
79
+ scrolled: ['[data-scroll-state~="scrolled"] &'],
80
+ // The same trick for the other half of that header: transparent over the
81
+ // hero, opaque while its menu panel is open. The panel cannot express it —
82
+ // the header is not inside the panel — and the header cannot know it without
83
+ // the page saying so.
84
+ 'overlay-open': ['[data-overlay-state~="open"] &']
79
85
  };
80
86
 
81
87
  export const PART_STYLE_STATES = Object.keys(STATE_SELECTORS);