@akinon/pz-theme 2.0.64-rc.0 → 2.0.64
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 +7 -31
- package/package.json +4 -4
- package/src/blocks/__tests__/account-button-block.test.tsx +115 -0
- package/src/blocks/account-button-block.tsx +39 -3
- package/src/chrome/__tests__/theme-config-source.test.ts +3 -0
- package/src/overlays/__tests__/modal-preview-host.test.tsx +36 -0
- package/src/overlays/__tests__/modal-registry.test.ts +37 -0
- package/src/overlays/modal-preview-host.tsx +6 -0
- package/src/overlays/modal-registry.ts +47 -2
- package/src/overlays/overlay-surface.tsx +76 -6
- package/src/overlays/theme-modal-mount.tsx +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,36 +1,12 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.64
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
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.64-rc.0
|
|
33
|
-
- @akinon/theme-contract@2.0.64-rc.0
|
|
3
|
+
## 2.0.64
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- d0687da: ZERO-4814: Add modal width units, backdrop paint and account-button guest modal trigger
|
|
8
|
+
- @akinon/next@2.0.64
|
|
9
|
+
- @akinon/theme-contract@2.0.64
|
|
34
10
|
|
|
35
11
|
## 2.0.63
|
|
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.64
|
|
4
|
+
"version": "2.0.64",
|
|
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.64
|
|
11
|
+
"@akinon/next": "2.0.64",
|
|
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.64
|
|
16
|
+
"@akinon/theme-contract": "2.0.64",
|
|
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.64
|
|
23
|
+
"@akinon/next": "2.0.64",
|
|
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",
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { fireEvent, render } from '@testing-library/react';
|
|
3
|
+
import AccountButtonBlock from '../account-button-block';
|
|
4
|
+
import { __resetOverlayBusForTests } from '../../overlays/overlay-bus';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The account button is the session fork of the header: a guest gets the way
|
|
8
|
+
* IN (login page, or a composed login modal opened in place), a signed-in
|
|
9
|
+
* shopper gets their account. The modal branch must never leak to the
|
|
10
|
+
* signed-in side — that is the whole reason it is this block's own property
|
|
11
|
+
* rather than the shared, unconditional On Click action.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
let mockSessionStatus = 'unauthenticated';
|
|
15
|
+
|
|
16
|
+
jest.mock('next-auth/react', () => ({
|
|
17
|
+
useSession: () => ({ data: null, status: mockSessionStatus }),
|
|
18
|
+
signIn: () => {},
|
|
19
|
+
signOut: () => {}
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
const renderBlock = (
|
|
23
|
+
properties: Record<string, unknown> = {},
|
|
24
|
+
extra: Record<string, unknown> = {}
|
|
25
|
+
) =>
|
|
26
|
+
render(
|
|
27
|
+
<AccountButtonBlock
|
|
28
|
+
{...({
|
|
29
|
+
block: {
|
|
30
|
+
id: 'account',
|
|
31
|
+
type: 'account-button',
|
|
32
|
+
label: 'Account',
|
|
33
|
+
properties,
|
|
34
|
+
styles: {},
|
|
35
|
+
order: 0,
|
|
36
|
+
hidden: false
|
|
37
|
+
},
|
|
38
|
+
placeholderId: 'header',
|
|
39
|
+
sectionId: 's1',
|
|
40
|
+
isDesigner: false,
|
|
41
|
+
...extra
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
43
|
+
} as any)}
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const link = (container: HTMLElement) =>
|
|
48
|
+
container.querySelector('[data-account-button]') as HTMLAnchorElement;
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
mockSessionStatus = 'unauthenticated';
|
|
52
|
+
__resetOverlayBusForTests();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('AccountButtonBlock', () => {
|
|
56
|
+
it('sends a guest to the login page by default', () => {
|
|
57
|
+
const { container } = renderBlock();
|
|
58
|
+
|
|
59
|
+
expect(link(container).getAttribute('href')).toBe('/auth');
|
|
60
|
+
expect(link(container).getAttribute('data-auth-state')).toBe('guest');
|
|
61
|
+
expect(link(container).getAttribute('aria-haspopup')).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('sends a signed-in shopper to their account', () => {
|
|
65
|
+
mockSessionStatus = 'authenticated';
|
|
66
|
+
const { container } = renderBlock();
|
|
67
|
+
|
|
68
|
+
expect(link(container).getAttribute('href')).toBe('/account');
|
|
69
|
+
expect(link(container).getAttribute('data-auth-state')).toBe(
|
|
70
|
+
'authenticated'
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('opens the configured modal for a guest instead of navigating', () => {
|
|
75
|
+
const { container } = renderBlock({
|
|
76
|
+
guestClick: 'open-modal',
|
|
77
|
+
guestModalId: 'm-login'
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Announced as a dialog trigger, with the login page kept as the
|
|
81
|
+
// no-JavaScript fallback on the anchor itself.
|
|
82
|
+
expect(link(container).getAttribute('aria-haspopup')).toBe('dialog');
|
|
83
|
+
expect(link(container).getAttribute('aria-expanded')).toBe('false');
|
|
84
|
+
expect(link(container).getAttribute('href')).toBe('/auth');
|
|
85
|
+
|
|
86
|
+
const click = fireEvent.click(link(container));
|
|
87
|
+
|
|
88
|
+
// The navigation is cancelled and the overlay bus now holds the modal
|
|
89
|
+
// open — aria-expanded tracks the bus, so it is the end-to-end proof.
|
|
90
|
+
expect(click).toBe(false);
|
|
91
|
+
expect(link(container).getAttribute('aria-expanded')).toBe('true');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('never offers the modal to a signed-in shopper', () => {
|
|
95
|
+
mockSessionStatus = 'authenticated';
|
|
96
|
+
const { container } = renderBlock({
|
|
97
|
+
guestClick: 'open-modal',
|
|
98
|
+
guestModalId: 'm-login'
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(link(container).getAttribute('href')).toBe('/account');
|
|
102
|
+
expect(link(container).getAttribute('aria-haspopup')).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('stays inert in the designer, where a click means selection', () => {
|
|
106
|
+
const { container } = renderBlock(
|
|
107
|
+
{ guestClick: 'open-modal', guestModalId: 'm-login' },
|
|
108
|
+
{ isDesigner: true }
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
fireEvent.click(link(container));
|
|
112
|
+
|
|
113
|
+
expect(link(container).getAttribute('aria-expanded')).toBe('false');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -4,6 +4,7 @@ import React from 'react';
|
|
|
4
4
|
import NextLink from 'next/link';
|
|
5
5
|
import { useSession } from 'next-auth/react';
|
|
6
6
|
import { getResponsiveValue, partAttrs } from '../utils';
|
|
7
|
+
import useBlockAction from '../hooks/use-block-action';
|
|
7
8
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
8
9
|
import { readToggle } from './product-block-utils';
|
|
9
10
|
|
|
@@ -22,8 +23,17 @@ const clampNumber = (
|
|
|
22
23
|
* Functional chrome block: session-aware account entry point. Renders a
|
|
23
24
|
* login link for guests and an account link for authenticated users; the
|
|
24
25
|
* editor owns placement, labels, icon and styling.
|
|
25
|
-
*
|
|
26
|
-
*
|
|
26
|
+
*
|
|
27
|
+
* A guest click can also open a composed modal instead of navigating
|
|
28
|
+
* (`guestClick: 'open-modal'` + `guestModalId`) — the login form arrives in
|
|
29
|
+
* place, while a signed-in shopper keeps going straight to their account.
|
|
30
|
+
* That branch is THIS block's, not the shared On Click action, because the
|
|
31
|
+
* shared action is unconditional: pointed at a login modal it would open it
|
|
32
|
+
* for the signed-in shopper too, who has nothing to log in to. The Login URL
|
|
33
|
+
* stays on the anchor as the no-JavaScript fallback.
|
|
34
|
+
*
|
|
35
|
+
* Properties: accountHref, loginHref, guestClick, guestModalId, accountLabel,
|
|
36
|
+
* loginLabel, showLabel, showIcon, iconSize, gap, labelPosition, ariaLabel.
|
|
27
37
|
* Parts: link, icon, label. The signed-in state stays readable from custom
|
|
28
38
|
* CSS through the block's own `data-auth-state` attribute.
|
|
29
39
|
*/
|
|
@@ -46,6 +56,25 @@ const AccountButtonBlock = ({
|
|
|
46
56
|
const label = isAuthenticated
|
|
47
57
|
? prop('accountLabel', 'Account')
|
|
48
58
|
: prop('loginLabel', 'Login');
|
|
59
|
+
|
|
60
|
+
const guestModalId = prop('guestModalId', '');
|
|
61
|
+
const guestOpensModal =
|
|
62
|
+
!isAuthenticated &&
|
|
63
|
+
prop('guestClick', 'navigate') === 'open-modal' &&
|
|
64
|
+
Boolean(guestModalId);
|
|
65
|
+
// Routed through the shared action hook so a guest trigger behaves exactly
|
|
66
|
+
// like every other modal trigger: inert in the designer, announced as a
|
|
67
|
+
// dialog trigger, aria-expanded tracking the bus. The synthetic properties
|
|
68
|
+
// make the hook act only on the guest branch.
|
|
69
|
+
const guestModalAction = useBlockAction(
|
|
70
|
+
{
|
|
71
|
+
properties: {
|
|
72
|
+
actionType: guestOpensModal ? 'open-modal' : 'none',
|
|
73
|
+
actionTarget: guestModalId
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
{ currentBreakpoint, isDesigner }
|
|
77
|
+
);
|
|
49
78
|
const showLabel =
|
|
50
79
|
getResponsiveValue(
|
|
51
80
|
block.properties?.showLabel,
|
|
@@ -94,10 +123,17 @@ const AccountButtonBlock = ({
|
|
|
94
123
|
data-auth-state={isAuthenticated ? 'authenticated' : 'guest'}
|
|
95
124
|
{...partAttrs('link')}
|
|
96
125
|
aria-label={ariaLabel || label}
|
|
126
|
+
{...guestModalAction.ariaProps}
|
|
97
127
|
// Navigating inside the designer iframe would drop the author out of the
|
|
98
128
|
// page they are editing.
|
|
99
129
|
onClick={(event) => {
|
|
100
|
-
if (isDesigner)
|
|
130
|
+
if (isDesigner) {
|
|
131
|
+
event.preventDefault();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
// Prevents the navigation itself when it fires — a trigger that also
|
|
135
|
+
// navigated would race its own modal.
|
|
136
|
+
guestModalAction.onClick?.(event);
|
|
101
137
|
}}
|
|
102
138
|
style={{
|
|
103
139
|
display: 'inline-flex',
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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}
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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}
|