@akinon/pz-theme 2.0.64-rc.0 → 2.0.65-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.
@@ -82,9 +82,12 @@ describe('readThemeConfig', () => {
82
82
  showClose: true,
83
83
  closeLabel: 'Close',
84
84
  showBackdrop: true,
85
+ backdropColor: '#000000',
86
+ backdropOpacity: 50,
85
87
  closeOnBackdrop: true,
86
88
  closeOnEscape: true,
87
89
  width: 640,
90
+ widthUnit: 'px',
88
91
  panelSide: 'right',
89
92
  sheetHeight: 60,
90
93
  lockScroll: true
@@ -122,6 +122,42 @@ describe('the modal a designer is composing', () => {
122
122
  expect(screen.getByRole('dialog').style.maxWidth).toBe('420px');
123
123
  });
124
124
 
125
+ it('follows a width unit edit — a relative modal tracks the viewport', () => {
126
+ render(<ModalPreviewHost />);
127
+
128
+ hold(mkModal({ chrome: { width: 80, widthUnit: '%' } }));
129
+ expect(screen.getByRole('dialog').style.maxWidth).toBe('80%');
130
+
131
+ hold(mkModal({ chrome: { width: 90, widthUnit: 'vw' } }));
132
+ expect(screen.getByRole('dialog').style.maxWidth).toBe('90vw');
133
+ });
134
+
135
+ it('follows a backdrop edit, and defaults to the historical dimming', () => {
136
+ render(<ModalPreviewHost />);
137
+
138
+ // The centred surface's root IS the backdrop.
139
+ hold(mkModal());
140
+ const backdropOf = () =>
141
+ (
142
+ document.querySelector(
143
+ '[data-theme-modal] [data-part="overlay"]'
144
+ ) as HTMLElement
145
+ ).style.background;
146
+
147
+ expect(backdropOf()).toBe('rgba(0, 0, 0, 0.5)');
148
+
149
+ hold(
150
+ mkModal({
151
+ chrome: { backdropColor: '#1a2b3c', backdropOpacity: 80 }
152
+ })
153
+ );
154
+ expect(backdropOf()).toBe('rgba(26, 43, 60, 0.8)');
155
+
156
+ // Fully see-through is a legitimate choice, not a missing value.
157
+ hold(mkModal({ chrome: { backdropOpacity: 0 } }));
158
+ expect(backdropOf()).toBe('rgba(0, 0, 0, 0)');
159
+ });
160
+
125
161
  it('follows a presentation edit without a save or a reload', () => {
126
162
  render(<ModalPreviewHost />);
127
163
 
@@ -50,9 +50,12 @@ describe('readThemeModals', () => {
50
50
  showClose: true,
51
51
  closeLabel: 'Close',
52
52
  showBackdrop: true,
53
+ backdropColor: '#000000',
54
+ backdropOpacity: 50,
53
55
  closeOnBackdrop: true,
54
56
  closeOnEscape: true,
55
57
  width: 640,
58
+ widthUnit: 'px',
56
59
  panelSide: 'right',
57
60
  sheetHeight: 60,
58
61
  lockScroll: true
@@ -105,6 +108,40 @@ describe('readThemeModals', () => {
105
108
  expect(chromeOf({ sheetHeight: 300 }).sheetHeight).toBe(100);
106
109
  });
107
110
 
111
+ it('normalizes the backdrop paint, refusing junk colors', () => {
112
+ // Entries saved before backdrop settings existed keep today's paint.
113
+ expect(chromeOf({}).backdropColor).toBe('#000000');
114
+ expect(chromeOf({}).backdropOpacity).toBe(50);
115
+
116
+ expect(chromeOf({ backdropColor: '#1a2b3c' }).backdropColor).toBe(
117
+ '#1a2b3c'
118
+ );
119
+ expect(chromeOf({ backdropColor: '#fff' }).backdropColor).toBe('#fff');
120
+ // A dropped declaration would silently undim the page — junk falls back.
121
+ expect(chromeOf({ backdropColor: 'dark' }).backdropColor).toBe('#000000');
122
+ expect(
123
+ chromeOf({ backdropColor: 'url(javascript:1)' }).backdropColor
124
+ ).toBe('#000000');
125
+
126
+ expect(chromeOf({ backdropOpacity: 0 }).backdropOpacity).toBe(0);
127
+ expect(chromeOf({ backdropOpacity: 350 }).backdropOpacity).toBe(100);
128
+ expect(chromeOf({ backdropOpacity: 'dim' }).backdropOpacity).toBe(50);
129
+ });
130
+
131
+ it('reads a width unit, clamping relative widths to the viewport scale', () => {
132
+ // Entries saved before units existed carry none — they stay px.
133
+ expect(chromeOf({}).widthUnit).toBe('px');
134
+ expect(chromeOf({ widthUnit: 'em' }).widthUnit).toBe('px');
135
+
136
+ expect(chromeOf({ width: 80, widthUnit: '%' }).width).toBe(80);
137
+ expect(chromeOf({ width: 80, widthUnit: '%' }).widthUnit).toBe('%');
138
+ // Relative units live on a 10–100 scale; a px-scale value snaps into it
139
+ // instead of producing a 640vw overlay.
140
+ expect(chromeOf({ width: 640, widthUnit: 'vw' }).width).toBe(100);
141
+ expect(chromeOf({ width: 3, widthUnit: '%' }).width).toBe(10);
142
+ expect(chromeOf({ widthUnit: '%' }).width).toBe(80);
143
+ });
144
+
108
145
  it('refuses an unknown panel side', () => {
109
146
  expect(chromeOf({ panelSide: 'top' }).panelSide).toBe('right');
110
147
  expect(chromeOf({ panelSide: 'left' }).panelSide).toBe('left');
@@ -121,6 +121,9 @@ export const ModalPreviewHost = ({
121
121
  closeOnOverlay={false}
122
122
  onDismiss={noop}
123
123
  width={chrome.width}
124
+ widthUnit={chrome.widthUnit}
125
+ backdropColor={chrome.backdropColor}
126
+ backdropOpacity={chrome.backdropOpacity}
124
127
  header={header}
125
128
  >
126
129
  {composition(modal.slug)}
@@ -134,6 +137,9 @@ export const ModalPreviewHost = ({
134
137
  closeOnOverlay={false}
135
138
  onDismiss={noop}
136
139
  width={chrome.width}
140
+ widthUnit={chrome.widthUnit}
141
+ backdropColor={chrome.backdropColor}
142
+ backdropOpacity={chrome.backdropOpacity}
137
143
  panelSide={chrome.panelSide}
138
144
  sheetHeight={chrome.sheetHeight}
139
145
  header={header}
@@ -1,6 +1,12 @@
1
1
  import { modalSlug } from '../chrome/placeholder-slugs';
2
2
  import { devWarn } from '../utils/dev-warn';
3
3
  import type { VisibilityRules } from '../utils/visibility-rules';
4
+ import {
5
+ OVERLAY_BACKDROP_COLOR,
6
+ OVERLAY_BACKDROP_OPACITY,
7
+ OVERLAY_WIDTH_UNITS,
8
+ type OverlayWidthUnit
9
+ } from './overlay-surface';
4
10
 
5
11
  /**
6
12
  * The modal registry: what modals this theme has, read off `theme-config`.
@@ -65,10 +71,19 @@ export interface ModalChrome {
65
71
  showClose: boolean;
66
72
  closeLabel: string;
67
73
  showBackdrop: boolean;
74
+ /** Backdrop paint. `#rrggbb` plus a 0–100 opacity; 0 is fully see-through. */
75
+ backdropColor: string;
76
+ backdropOpacity: number;
68
77
  closeOnBackdrop: boolean;
69
78
  closeOnEscape: boolean;
70
- /** Drawer/anchored width, and the centred modal's max width, in px. */
79
+ /** Drawer/anchored width, and the centred modal's max width, in `widthUnit`. */
71
80
  width: number;
81
+ /**
82
+ * Unit of `width`. Relative units (`%`, `vw`) let a modal follow the
83
+ * viewport — a fixed pixel count cannot express "80% of the screen".
84
+ * Absent on entries saved before units existed, which reads as px.
85
+ */
86
+ widthUnit: OverlayWidthUnit;
72
87
  panelSide: 'left' | 'right';
73
88
  /** Sheet height as a percentage of the viewport. */
74
89
  sheetHeight: number;
@@ -146,6 +161,18 @@ const readAutoTrigger = (value: unknown): ModalAutoTrigger | undefined => {
146
161
  };
147
162
  };
148
163
 
164
+ const HEX_COLOR_PATTERN = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
165
+
166
+ /**
167
+ * A junk color falls back instead of passing through: the surface would hand
168
+ * the browser an invalid declaration, the declaration would be dropped, and
169
+ * the page behind the modal would silently lose its dimming.
170
+ */
171
+ const asHexColor = (value: unknown, fallback: string): string =>
172
+ typeof value === 'string' && HEX_COLOR_PATTERN.test(value.trim())
173
+ ? value.trim()
174
+ : fallback;
175
+
149
176
  const asToggle = (value: unknown, fallback: boolean): boolean =>
150
177
  typeof value === 'boolean' ? value : fallback;
151
178
 
@@ -170,6 +197,11 @@ const readChrome = (value: unknown, name: string): ModalChrome => {
170
197
  const showClose = asToggle(raw.showClose, true);
171
198
  const closeOnBackdrop = asToggle(raw.closeOnBackdrop, true);
172
199
  const closeOnEscape = asToggle(raw.closeOnEscape, true);
200
+ const widthUnit = asOption<OverlayWidthUnit>(
201
+ raw.widthUnit,
202
+ OVERLAY_WIDTH_UNITS,
203
+ 'px'
204
+ );
173
205
 
174
206
  return {
175
207
  ariaLabel: asString(raw.ariaLabel) || name,
@@ -183,9 +215,22 @@ const readChrome = (value: unknown, name: string): ModalChrome => {
183
215
  showClose: showClose || (!closeOnBackdrop && !closeOnEscape),
184
216
  closeLabel: asString(raw.closeLabel) || 'Close',
185
217
  showBackdrop: asToggle(raw.showBackdrop, true),
218
+ backdropColor: asHexColor(raw.backdropColor, OVERLAY_BACKDROP_COLOR),
219
+ backdropOpacity: asClamped(
220
+ raw.backdropOpacity,
221
+ OVERLAY_BACKDROP_OPACITY,
222
+ 0,
223
+ 100
224
+ ),
186
225
  closeOnBackdrop,
187
226
  closeOnEscape,
188
- width: asClamped(raw.width, 640, 240, 1600),
227
+ // px keeps its historical clamp; the relative units live on a 10–100
228
+ // scale of the viewport — 8000vw is not a width, it is a typo.
229
+ width:
230
+ widthUnit === 'px'
231
+ ? asClamped(raw.width, 640, 240, 1600)
232
+ : asClamped(raw.width, 80, 10, 100),
233
+ widthUnit,
189
234
  panelSide: raw.panelSide === 'left' ? 'left' : 'right',
190
235
  sheetHeight: asClamped(raw.sheetHeight, 60, 20, 100),
191
236
  lockScroll: asToggle(raw.lockScroll, true)
@@ -31,6 +31,21 @@ export const OVERLAY_PRESENTATIONS: OverlayPresentation[] = [
31
31
  'anchored'
32
32
  ];
33
33
 
34
+ export type OverlayWidthUnit = 'px' | '%' | 'vw';
35
+
36
+ export const OVERLAY_WIDTH_UNITS: OverlayWidthUnit[] = ['px', '%', 'vw'];
37
+
38
+ /**
39
+ * The overlay width as CSS. Unit-less callers (the panel block, whose markup
40
+ * `panel-legacy-golden.json` pins byte for byte) stay in px and keep emitting
41
+ * the exact strings they always did; a composed modal may carry `%` or `vw`
42
+ * so its surface can follow the viewport instead of a fixed pixel count.
43
+ */
44
+ export const resolveOverlayWidth = (
45
+ width: number,
46
+ widthUnit: OverlayWidthUnit = 'px'
47
+ ): string => `${width}${widthUnit}`;
48
+
34
49
  export const clampOverlayNumber = (
35
50
  value: unknown,
36
51
  fallback: number,
@@ -43,6 +58,48 @@ export const clampOverlayNumber = (
43
58
  return Math.min(max, Math.max(min, numeric));
44
59
  };
45
60
 
61
+ /** Backdrop paint defaults — what every published surface shipped with. */
62
+ export const OVERLAY_BACKDROP_COLOR = '#000000';
63
+ export const OVERLAY_BACKDROP_OPACITY = 50;
64
+
65
+ const HEX_COLOR_PATTERN = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
66
+
67
+ /**
68
+ * The backdrop as CSS. The defaults reproduce the historical paint byte for
69
+ * byte — `rgba(0, 0, 0, 0.5)` — which the panel block's golden markup pins,
70
+ * so a surface given no backdrop settings cannot drift. Junk color falls
71
+ * back to black rather than to an invalid declaration the browser drops,
72
+ * which would leave the page behind the modal fully undimmed.
73
+ */
74
+ export const resolveOverlayBackdrop = (
75
+ color?: string,
76
+ opacity?: number
77
+ ): string => {
78
+ const hex = HEX_COLOR_PATTERN.test(color || '')
79
+ ? (color as string)
80
+ : OVERLAY_BACKDROP_COLOR;
81
+ const digits = hex.slice(1);
82
+ const full =
83
+ digits.length === 3
84
+ ? digits
85
+ .split('')
86
+ .map((digit) => digit + digit)
87
+ .join('')
88
+ : digits;
89
+
90
+ const numericOpacity = Number(opacity);
91
+ const clampedOpacity = Number.isFinite(numericOpacity)
92
+ ? Math.min(100, Math.max(0, numericOpacity))
93
+ : OVERLAY_BACKDROP_OPACITY;
94
+
95
+ const r = parseInt(full.slice(0, 2), 16);
96
+ const g = parseInt(full.slice(2, 4), 16);
97
+ const b = parseInt(full.slice(4, 6), 16);
98
+ const alpha = Number((clampedOpacity / 100).toFixed(2));
99
+
100
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
101
+ };
102
+
46
103
  /** The panel's own box, before geometry places it. */
47
104
  export const overlayPanelSurface: React.CSSProperties = {
48
105
  overflow: 'auto',
@@ -55,8 +112,10 @@ export const overlayPanelSurface: React.CSSProperties = {
55
112
 
56
113
  export interface OverlayGeometryInput {
57
114
  presentation: OverlayPresentation;
58
- /** Drawer/anchored width and the centred modal's max width, in px. */
115
+ /** Drawer/anchored width and the centred modal's max width, in `widthUnit`. */
59
116
  width: number;
117
+ /** Unit of `width`; px unless the composed modal's chrome says otherwise. */
118
+ widthUnit?: OverlayWidthUnit;
60
119
  panelSide?: string;
61
120
  /** Sheet height as a percentage of the viewport. */
62
121
  sheetHeight?: number;
@@ -74,6 +133,7 @@ export interface OverlayGeometryInput {
74
133
  export const resolveOverlayPanelGeometry = ({
75
134
  presentation,
76
135
  width,
136
+ widthUnit = 'px',
77
137
  panelSide = 'right',
78
138
  sheetHeight = 60,
79
139
  anchorAlign = 'start',
@@ -85,7 +145,7 @@ export const resolveOverlayPanelGeometry = ({
85
145
  top: 0,
86
146
  bottom: 0,
87
147
  [panelSide === 'left' ? 'left' : 'right']: 0,
88
- width: `min(${width}px, 92vw)`,
148
+ width: `min(${resolveOverlayWidth(width, widthUnit)}, 92vw)`,
89
149
  boxShadow: '0 0 24px rgba(0, 0, 0, 0.2)'
90
150
  };
91
151
  }
@@ -113,7 +173,7 @@ export const resolveOverlayPanelGeometry = ({
113
173
  position: 'absolute',
114
174
  top: `calc(100% + ${anchorOffset}px)`,
115
175
  zIndex: 1000,
116
- width: `${width}px`,
176
+ width: resolveOverlayWidth(width, widthUnit),
117
177
  maxWidth: '92vw',
118
178
  maxHeight: '80vh',
119
179
  borderRadius: '4px',
@@ -195,6 +255,9 @@ export interface OverlaySurfaceProps extends OverlayGeometryInput {
195
255
  overlayProps: OverlayHandleProps;
196
256
  ariaLabel: string;
197
257
  showBackdrop: boolean;
258
+ /** Backdrop paint; absent means the historical rgba(0, 0, 0, 0.5). */
259
+ backdropColor?: string;
260
+ backdropOpacity?: number;
198
261
  closeOnOverlay: boolean;
199
262
  onDismiss: () => void;
200
263
  /** Rendered inside the panel, above the authored content. */
@@ -231,6 +294,9 @@ export const CentredModalSurface = ({
231
294
  closeOnOverlay,
232
295
  onDismiss,
233
296
  width,
297
+ widthUnit,
298
+ backdropColor,
299
+ backdropOpacity,
234
300
  header,
235
301
  children,
236
302
  markers
@@ -252,7 +318,7 @@ export const CentredModalSurface = ({
252
318
  alignItems: 'center',
253
319
  justifyContent: 'center',
254
320
  padding: '16px',
255
- background: 'rgba(0, 0, 0, 0.5)'
321
+ background: resolveOverlayBackdrop(backdropColor, backdropOpacity)
256
322
  }}
257
323
  >
258
324
  <div
@@ -267,7 +333,7 @@ export const CentredModalSurface = ({
267
333
  style={{
268
334
  position: 'relative',
269
335
  width: '100%',
270
- maxWidth: `${width}px`,
336
+ maxWidth: resolveOverlayWidth(width, widthUnit),
271
337
  maxHeight: '80vh',
272
338
  overflow: 'auto',
273
339
  padding: '24px',
@@ -295,9 +361,12 @@ export const LayeredOverlaySurface = ({
295
361
  overlayProps,
296
362
  ariaLabel,
297
363
  showBackdrop,
364
+ backdropColor,
365
+ backdropOpacity,
298
366
  closeOnOverlay,
299
367
  onDismiss,
300
368
  width,
369
+ widthUnit,
301
370
  panelSide,
302
371
  sheetHeight,
303
372
  anchorAlign,
@@ -316,7 +385,7 @@ export const LayeredOverlaySurface = ({
316
385
  style={{
317
386
  position: 'absolute',
318
387
  inset: 0,
319
- background: 'rgba(0, 0, 0, 0.5)'
388
+ background: resolveOverlayBackdrop(backdropColor, backdropOpacity)
320
389
  }}
321
390
  />
322
391
  ) : null;
@@ -337,6 +406,7 @@ export const LayeredOverlaySurface = ({
337
406
  ...resolveOverlayPanelGeometry({
338
407
  presentation,
339
408
  width,
409
+ widthUnit,
340
410
  panelSide,
341
411
  sheetHeight,
342
412
  anchorAlign,
@@ -140,6 +140,9 @@ const ThemeModalMount = ({
140
140
  closeOnOverlay={chrome.closeOnBackdrop}
141
141
  onDismiss={dismiss}
142
142
  width={chrome.width}
143
+ widthUnit={chrome.widthUnit}
144
+ backdropColor={chrome.backdropColor}
145
+ backdropOpacity={chrome.backdropOpacity}
143
146
  header={header}
144
147
  >
145
148
  {children}
@@ -153,6 +156,9 @@ const ThemeModalMount = ({
153
156
  closeOnOverlay={chrome.closeOnBackdrop}
154
157
  onDismiss={dismiss}
155
158
  width={chrome.width}
159
+ widthUnit={chrome.widthUnit}
160
+ backdropColor={chrome.backdropColor}
161
+ backdropOpacity={chrome.backdropOpacity}
156
162
  panelSide={chrome.panelSide}
157
163
  sheetHeight={chrome.sheetHeight}
158
164
  header={header}