@adobe-commerce/elsie 1.3.1-alpha02 → 1.4.0-alpha2
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/package.json +4 -4
- package/src/components/Button/Button.css +9 -9
- package/src/components/CartItem/CartItem.css +0 -5
- package/src/components/Icon/Icon.stories.tsx +124 -8
- package/src/components/Icon/Icon.tsx +162 -7
- package/src/components/Modal/Modal.css +2 -2
- package/src/components/Modal/Modal.stories.tsx +88 -4
- package/src/components/Modal/Modal.tsx +40 -37
- package/src/components/Portal/Portal.stories.tsx +145 -0
- package/src/components/Portal/Portal.tsx +49 -0
- package/src/icons/Edit.svg +1 -0
- package/src/icons/index.ts +1 -0
- package/src/lib/aem/assets.ts +323 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adobe-commerce/elsie",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0-alpha2",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
5
5
|
"description": "Domain Package SDK",
|
|
6
6
|
"engines": {
|
|
@@ -27,10 +27,10 @@
|
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@adobe-commerce/event-bus": "~1.0.0",
|
|
30
|
-
"@adobe-commerce/fetch-graphql": "~1.
|
|
31
|
-
"@adobe-commerce/recaptcha": "~1.0.
|
|
30
|
+
"@adobe-commerce/fetch-graphql": "~1.1.0",
|
|
31
|
+
"@adobe-commerce/recaptcha": "~1.0.1",
|
|
32
32
|
"@adobe-commerce/storefront-design": "~1.0.0",
|
|
33
|
-
"@dropins/build-tools": "~1.0.
|
|
33
|
+
"@dropins/build-tools": "~1.0.1",
|
|
34
34
|
"preact": "~10.22.1",
|
|
35
35
|
"vite-plugin-banner": "^0.8.0"
|
|
36
36
|
},
|
|
@@ -55,8 +55,8 @@
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/* Primary */
|
|
58
|
-
.dropin-button--primary,
|
|
59
|
-
a.dropin-button--primary,
|
|
58
|
+
button.dropin-button.dropin-button--primary,
|
|
59
|
+
a.dropin-button.dropin-button--primary,
|
|
60
60
|
.dropin-iconButton--primary {
|
|
61
61
|
border: none;
|
|
62
62
|
background: var(--color-brand-500) 0 0% no-repeat padding-box;
|
|
@@ -72,8 +72,8 @@ a.dropin-button--primary,
|
|
|
72
72
|
padding: var(--spacing-xsmall);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
.dropin-button--primary--disabled,
|
|
76
|
-
a.dropin-button--primary--disabled,
|
|
75
|
+
button.dropin-button.dropin-button--primary--disabled,
|
|
76
|
+
a.dropin-button.dropin-button--primary--disabled,
|
|
77
77
|
.dropin-iconButton--primary--disabled {
|
|
78
78
|
background: var(--color-neutral-300) 0 0% no-repeat padding-box;
|
|
79
79
|
color: var(--color-neutral-500);
|
|
@@ -82,21 +82,21 @@ a.dropin-button--primary--disabled,
|
|
|
82
82
|
user-select: none;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
.dropin-button--primary:hover,
|
|
86
|
-
a.dropin-button--primary:hover,
|
|
85
|
+
button.dropin-button.dropin-button--primary:hover,
|
|
86
|
+
a.dropin-button.dropin-button--primary:hover,
|
|
87
87
|
.dropin-iconButton--primary:hover,
|
|
88
|
-
.dropin-button--primary:focus:hover,
|
|
88
|
+
button.dropin-button.dropin-button--primary:focus:hover,
|
|
89
89
|
.dropin-iconButton--primary:focus:hover {
|
|
90
90
|
background-color: var(--color-button-hover);
|
|
91
91
|
text-decoration: none;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
.dropin-button--primary:focus,
|
|
94
|
+
button.dropin-button.dropin-button--primary:focus,
|
|
95
95
|
.dropin-iconButton--primary:focus {
|
|
96
96
|
background-color: var(--color-brand-500);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
.dropin-button--primary:hover:active,
|
|
99
|
+
button.dropin-button.dropin-button--primary:hover:active,
|
|
100
100
|
.dropin-iconButton--primary:hover:active {
|
|
101
101
|
background-color: var(--color-button-active);
|
|
102
102
|
}
|
|
@@ -241,15 +241,10 @@
|
|
|
241
241
|
}
|
|
242
242
|
|
|
243
243
|
.dropin-cart-item__price,
|
|
244
|
-
.dropin-cart-item__footer div,
|
|
245
244
|
.dropin-cart-item__attributes div {
|
|
246
245
|
margin-top: var(--group-spacing);
|
|
247
246
|
}
|
|
248
247
|
|
|
249
|
-
.dropin-cart-item__footer p {
|
|
250
|
-
margin-top: 0;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
248
|
.dropin-cart-item__quantity:not(.dropin-cart-item__quantity--edit) {
|
|
254
249
|
display: none;
|
|
255
250
|
}
|
|
@@ -14,8 +14,13 @@ import * as Icons from '@adobe-commerce/elsie/icons';
|
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Use Icons as symbols or metaphors to communicate and enhance the user experience.
|
|
17
|
+
*
|
|
18
|
+
* The Icon component supports three source types:
|
|
19
|
+
* - Direct component imports
|
|
20
|
+
* - Icon names from the built-in icon set
|
|
21
|
+
* - SVGs from URLs (supports URLs that match the host domain)
|
|
17
22
|
*/
|
|
18
|
-
const meta: Meta<
|
|
23
|
+
const meta: Meta<StoryIconProps> = {
|
|
19
24
|
title: 'Components/Icon',
|
|
20
25
|
component: Icon,
|
|
21
26
|
argTypes: {
|
|
@@ -25,6 +30,13 @@ const meta: Meta<IconProps> = {
|
|
|
25
30
|
control: {
|
|
26
31
|
type: 'select',
|
|
27
32
|
},
|
|
33
|
+
description: 'Select a built-in icon',
|
|
34
|
+
},
|
|
35
|
+
url: {
|
|
36
|
+
control: {
|
|
37
|
+
type: 'text',
|
|
38
|
+
},
|
|
39
|
+
description: 'Or enter a URL to an external SVG (this takes priority over icon selection)',
|
|
28
40
|
},
|
|
29
41
|
size: {
|
|
30
42
|
control: 'select',
|
|
@@ -33,6 +45,7 @@ const meta: Meta<IconProps> = {
|
|
|
33
45
|
stroke: {
|
|
34
46
|
control: 'select',
|
|
35
47
|
options: ['1', '2', '3', '4'],
|
|
48
|
+
description: 'Stroke width. Works only for stroke-based icons.',
|
|
36
49
|
},
|
|
37
50
|
title: {
|
|
38
51
|
control: 'text',
|
|
@@ -43,7 +56,11 @@ const meta: Meta<IconProps> = {
|
|
|
43
56
|
|
|
44
57
|
export default meta;
|
|
45
58
|
|
|
46
|
-
type Story = StoryObj<
|
|
59
|
+
type Story = StoryObj<StoryIconProps>;
|
|
60
|
+
|
|
61
|
+
interface StoryIconProps extends IconProps {
|
|
62
|
+
url?: string;
|
|
63
|
+
}
|
|
47
64
|
|
|
48
65
|
/**
|
|
49
66
|
* ```ts
|
|
@@ -52,25 +69,43 @@ type Story = StoryObj<IconProps>;
|
|
|
52
69
|
*/
|
|
53
70
|
|
|
54
71
|
export const Primary: Story = {
|
|
72
|
+
render: ({ url, source, ...args }: StoryIconProps) => {
|
|
73
|
+
const iconSource = url || source;
|
|
74
|
+
return <Icon {...args} source={iconSource as any} />;
|
|
75
|
+
},
|
|
55
76
|
args: {
|
|
56
77
|
source: Cart,
|
|
57
78
|
},
|
|
58
79
|
};
|
|
59
80
|
|
|
60
81
|
export const Lazy: Story = {
|
|
61
|
-
|
|
62
|
-
source
|
|
63
|
-
|
|
64
|
-
},
|
|
82
|
+
render: ({ url, source, ...args }: StoryIconProps) => {
|
|
83
|
+
const iconSource = url || source;
|
|
84
|
+
return <Icon {...args} source={iconSource as any} />;
|
|
65
85
|
},
|
|
66
86
|
args: {
|
|
67
87
|
source: 'Cart',
|
|
68
88
|
},
|
|
69
89
|
};
|
|
70
90
|
|
|
71
|
-
export const
|
|
91
|
+
export const AllBuiltInIcons: Story = {
|
|
72
92
|
argTypes: {
|
|
73
|
-
style: Object
|
|
93
|
+
style: Object,
|
|
94
|
+
url: {
|
|
95
|
+
table: {
|
|
96
|
+
disable: true
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
source: {
|
|
100
|
+
table: {
|
|
101
|
+
disable: true
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
title: {
|
|
105
|
+
table: {
|
|
106
|
+
disable: true
|
|
107
|
+
}
|
|
108
|
+
}
|
|
74
109
|
},
|
|
75
110
|
args: {
|
|
76
111
|
style: {
|
|
@@ -96,3 +131,84 @@ export const AllIcons: Story = {
|
|
|
96
131
|
</div>
|
|
97
132
|
),
|
|
98
133
|
};
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
export const UrlExamples: Story = {
|
|
137
|
+
argTypes: {
|
|
138
|
+
source: {
|
|
139
|
+
table: {
|
|
140
|
+
disable: true
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
title: {
|
|
144
|
+
table: {
|
|
145
|
+
disable: true
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
render: ({ url, ...args }: StoryIconProps) => (
|
|
150
|
+
<div style={{
|
|
151
|
+
display: 'grid',
|
|
152
|
+
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
|
153
|
+
gap: '2rem',
|
|
154
|
+
padding: '1rem'
|
|
155
|
+
}}>
|
|
156
|
+
<div style={{
|
|
157
|
+
border: '1px solid #e1e5e9',
|
|
158
|
+
borderRadius: '8px',
|
|
159
|
+
padding: '1.5rem',
|
|
160
|
+
textAlign: 'center',
|
|
161
|
+
backgroundColor: '#f8f9fa'
|
|
162
|
+
}}>
|
|
163
|
+
<h3 style={{ margin: '0 0 1rem 0', color: '#2c3e50' }}>✅ Valid URL</h3>
|
|
164
|
+
<Icon
|
|
165
|
+
source={url || `${window.location.origin}/favicon.svg`}
|
|
166
|
+
size="12"
|
|
167
|
+
title="logo icon from common domain"
|
|
168
|
+
aria-label="Star icon loaded from external URL"
|
|
169
|
+
{...args}
|
|
170
|
+
/>
|
|
171
|
+
<p style={{
|
|
172
|
+
fontSize: '12px',
|
|
173
|
+
color: '#495057',
|
|
174
|
+
margin: '0.5rem 0 0 0',
|
|
175
|
+
wordBreak: 'break-all'
|
|
176
|
+
}}>
|
|
177
|
+
{url ? `Displays icon from: ${url}` : `Displays icon from ${window.location.origin}/favicon.svg`}
|
|
178
|
+
</p>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<div style={{
|
|
182
|
+
border: '1px solid #f8d7da',
|
|
183
|
+
borderRadius: '8px',
|
|
184
|
+
padding: '1.5rem',
|
|
185
|
+
textAlign: 'center',
|
|
186
|
+
backgroundColor: '#f8d7da'
|
|
187
|
+
}}>
|
|
188
|
+
<h3 style={{ margin: '0 0 1rem 0', color: '#721c24' }}>❌ Invalid URL</h3>
|
|
189
|
+
<Icon
|
|
190
|
+
source="https://invalid-url.com/icon.svg"
|
|
191
|
+
size="32"
|
|
192
|
+
title="Failed to load icon"
|
|
193
|
+
aria-label="Icon that failed to load"
|
|
194
|
+
/>
|
|
195
|
+
<p style={{
|
|
196
|
+
fontSize: '12px',
|
|
197
|
+
color: '#721c24',
|
|
198
|
+
margin: '0.5rem 0 0 0',
|
|
199
|
+
wordBreak: 'break-all'
|
|
200
|
+
}}>
|
|
201
|
+
Shows empty SVG
|
|
202
|
+
</p>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
</div>
|
|
206
|
+
),
|
|
207
|
+
parameters: {
|
|
208
|
+
docs: {
|
|
209
|
+
description: {
|
|
210
|
+
story: 'Examples of different URL formats supported by the Icon component.',
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
};
|
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
* Copyright 2024 Adobe
|
|
3
3
|
* All Rights Reserved.
|
|
4
4
|
*
|
|
5
|
-
* NOTICE: Adobe permits you to use, modify, and distribute this
|
|
6
|
-
* file in accordance with the terms of the Adobe license agreement
|
|
7
|
-
* accompanying it.
|
|
5
|
+
* NOTICE: Adobe permits you to use, modify, and distribute this
|
|
6
|
+
* file in accordance with the terms of the Adobe license agreement
|
|
7
|
+
* accompanying it.
|
|
8
8
|
*******************************************************************/
|
|
9
9
|
|
|
10
10
|
import { FunctionComponent } from 'preact';
|
|
11
11
|
import { classes } from '@adobe-commerce/elsie/lib/classes';
|
|
12
12
|
import { lazy, Suspense, SVGProps } from 'preact/compat';
|
|
13
|
+
import { useState, useEffect } from 'preact/hooks';
|
|
13
14
|
|
|
14
15
|
import '@adobe-commerce/elsie/components/Icon/Icon.css';
|
|
15
16
|
|
|
@@ -62,12 +63,14 @@ const lazyIcons = {
|
|
|
62
63
|
Coupon: lazy(() => import('@adobe-commerce/elsie/icons/Coupon.svg')),
|
|
63
64
|
Gift: lazy(() => import('@adobe-commerce/elsie/icons/Gift.svg')),
|
|
64
65
|
GiftCard: lazy(() => import('@adobe-commerce/elsie/icons/GiftCard.svg')),
|
|
66
|
+
Edit: lazy(() => import('@adobe-commerce/elsie/icons/Edit.svg')),
|
|
65
67
|
};
|
|
66
68
|
|
|
67
69
|
export interface IconProps extends Omit<SVGProps<SVGSVGElement>, 'size'> {
|
|
68
|
-
source
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
source?:
|
|
71
|
+
| FunctionComponent<SVGProps<SVGSVGElement> & { title?: string }>
|
|
72
|
+
| IconType
|
|
73
|
+
| string;
|
|
71
74
|
size?: '12' | '16' | '24' | '32' | '64' | '80';
|
|
72
75
|
stroke?: '1' | '2' | '3' | '4';
|
|
73
76
|
className?: string;
|
|
@@ -78,6 +81,136 @@ export type IconNode = FunctionComponent<
|
|
|
78
81
|
SVGProps<SVGSVGElement> & { title?: string }
|
|
79
82
|
>;
|
|
80
83
|
|
|
84
|
+
function isValidUrl(source: string): boolean { // check for URL from same domain
|
|
85
|
+
try {
|
|
86
|
+
if (source.startsWith('//')) {
|
|
87
|
+
const absoluteUrl = `${window.location.protocol}${source}`;
|
|
88
|
+
const url = new URL(absoluteUrl);
|
|
89
|
+
if (url.hostname !== window.location.hostname) {
|
|
90
|
+
console.error(
|
|
91
|
+
`[Icon] External URL rejected for security: ${source} - Only same-domain URLs are allowed`
|
|
92
|
+
);
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
const url = new URL(source);
|
|
98
|
+
|
|
99
|
+
if (url.hostname !== window.location.hostname) {
|
|
100
|
+
console.error(`[Icon] External URL rejected for security: ${source} - Only same-domain URLs are allowed`);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return true;
|
|
105
|
+
} catch {
|
|
106
|
+
console.error(`[Icon] Invalid URL format: ${source}`);
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function UrlSvgLoader({
|
|
112
|
+
url,
|
|
113
|
+
...props
|
|
114
|
+
}: SVGProps<SVGSVGElement> & { url: string }) {
|
|
115
|
+
const [svgContent, setSvgContent] = useState<string>('');
|
|
116
|
+
const [loading, setLoading] = useState(true);
|
|
117
|
+
const [error, setError] = useState(false);
|
|
118
|
+
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
fetch(url)
|
|
121
|
+
.then(response => {
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
console.error(`[Icon] Failed to fetch SVG: ${response.status} ${response.statusText}`);
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
return response.text();
|
|
127
|
+
})
|
|
128
|
+
.then(content => {
|
|
129
|
+
// Check if content is valid SVG
|
|
130
|
+
try {
|
|
131
|
+
const parser = new DOMParser();
|
|
132
|
+
const doc = parser.parseFromString(content, "image/svg+xml");
|
|
133
|
+
const svg = doc.querySelector('svg');
|
|
134
|
+
if (!svg) {
|
|
135
|
+
throw new Error("No <svg> element found");
|
|
136
|
+
}
|
|
137
|
+
// Success!
|
|
138
|
+
} catch(e: unknown) {
|
|
139
|
+
if (e instanceof Error ) {
|
|
140
|
+
console.error(`[Icon] Invalid SVG content from ${url}: ${e.message}`);
|
|
141
|
+
} else {
|
|
142
|
+
console.error(`[Icon] Invalid SVG content from ${url}: ${String(e)}`);
|
|
143
|
+
}
|
|
144
|
+
setError(true);
|
|
145
|
+
setLoading(false);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
// Process SVG content to ensure proper sizing and accessibility
|
|
150
|
+
let processedContent = content;
|
|
151
|
+
|
|
152
|
+
if (props.width) {
|
|
153
|
+
processedContent = processedContent.replace(
|
|
154
|
+
/<svg([^>]*)\s+width\s*=\s*["'][^"']*["']/gi,
|
|
155
|
+
'<svg$1'
|
|
156
|
+
);
|
|
157
|
+
processedContent = processedContent.replace(
|
|
158
|
+
/<svg/i,
|
|
159
|
+
`<svg width="${props.width}"`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (props.height) {
|
|
164
|
+
processedContent = processedContent.replace(
|
|
165
|
+
/<svg([^>]*)\s+height\s*=\s*["'][^"']*["']/gi,
|
|
166
|
+
'<svg$1'
|
|
167
|
+
);
|
|
168
|
+
processedContent = processedContent.replace(
|
|
169
|
+
/<svg/i,
|
|
170
|
+
`<svg height="${props.height}"`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (props.title) {
|
|
175
|
+
processedContent = processedContent.replace(/<title[^>]*>.*?<\/title>/gi, '');
|
|
176
|
+
processedContent = processedContent.replace(
|
|
177
|
+
/<svg([^>]*)>/i,
|
|
178
|
+
`<svg$1><title>${props.title}</title>`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
setSvgContent(processedContent);
|
|
183
|
+
setLoading(false);
|
|
184
|
+
})
|
|
185
|
+
.catch((error) => {
|
|
186
|
+
if (error instanceof Error) {
|
|
187
|
+
console.error(`[Icon] ${error.message}`);
|
|
188
|
+
} else {
|
|
189
|
+
console.error(`[Icon] ${String(error)}`);
|
|
190
|
+
}
|
|
191
|
+
setError(true);
|
|
192
|
+
setLoading(false);
|
|
193
|
+
});
|
|
194
|
+
}, [url, props.width, props.height, props.title]);
|
|
195
|
+
|
|
196
|
+
if (loading || error) {
|
|
197
|
+
return <svg {...props} />;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<span
|
|
202
|
+
className={props.className}
|
|
203
|
+
style={{
|
|
204
|
+
width: String(props.width),
|
|
205
|
+
height: String(props.height),
|
|
206
|
+
display: 'inline-flex',
|
|
207
|
+
lineHeight: 0,
|
|
208
|
+
}}
|
|
209
|
+
dangerouslySetInnerHTML={{ __html: svgContent }}
|
|
210
|
+
/>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
81
214
|
export function Icon({
|
|
82
215
|
source: Source,
|
|
83
216
|
size = '24',
|
|
@@ -86,7 +219,6 @@ export function Icon({
|
|
|
86
219
|
className,
|
|
87
220
|
...props
|
|
88
221
|
}: IconProps) {
|
|
89
|
-
const LazyIcon = typeof Source === 'string' ? lazyIcons[Source] : null;
|
|
90
222
|
|
|
91
223
|
const defaultProps = {
|
|
92
224
|
className: classes([
|
|
@@ -99,10 +231,33 @@ export function Icon({
|
|
|
99
231
|
viewBox,
|
|
100
232
|
};
|
|
101
233
|
|
|
234
|
+
// Only validate strings that look like URLs (start with http, //, or /)
|
|
235
|
+
const isLikelyUrl =
|
|
236
|
+
typeof Source === 'string' &&
|
|
237
|
+
(Source.startsWith('http') ||
|
|
238
|
+
Source.startsWith('//') ||
|
|
239
|
+
Source.startsWith('/'));
|
|
240
|
+
|
|
241
|
+
if (isLikelyUrl && isValidUrl(Source)) {
|
|
242
|
+
return (
|
|
243
|
+
<Suspense fallback={<svg {...props} {...defaultProps} />}>
|
|
244
|
+
<UrlSvgLoader url={Source} {...props} {...defaultProps}/>
|
|
245
|
+
</Suspense>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const LazyIcon = typeof Source === 'string' && Source in lazyIcons
|
|
250
|
+
? lazyIcons[Source as IconType]
|
|
251
|
+
: null;
|
|
252
|
+
|
|
253
|
+
const isRejectedUrl = isLikelyUrl && !isValidUrl(Source);
|
|
254
|
+
|
|
102
255
|
return (
|
|
103
256
|
<Suspense fallback={<svg {...props} {...defaultProps} />}>
|
|
104
257
|
{LazyIcon ? (
|
|
105
258
|
<LazyIcon {...props} {...defaultProps} />
|
|
259
|
+
) : isRejectedUrl ? (
|
|
260
|
+
<svg {...props} {...defaultProps} />
|
|
106
261
|
) : (
|
|
107
262
|
// @ts-ignore
|
|
108
263
|
<Source {...props} {...defaultProps} />
|
|
@@ -11,7 +11,8 @@ import { Meta, StoryObj } from '@storybook/preact';
|
|
|
11
11
|
import { Modal as component, ModalProps } from './Modal';
|
|
12
12
|
import { useState } from 'preact/hooks';
|
|
13
13
|
import { Button } from '../Button';
|
|
14
|
-
import { expect, userEvent, within } from '@storybook/test';
|
|
14
|
+
import { expect, userEvent, within, waitFor } from '@storybook/test';
|
|
15
|
+
import { useText } from '@adobe-commerce/elsie/i18n';
|
|
15
16
|
|
|
16
17
|
const meta: Meta<ModalProps> = {
|
|
17
18
|
title: 'Components/Modal',
|
|
@@ -107,13 +108,21 @@ export const SmallModal: Story = {
|
|
|
107
108
|
const canvas = within(canvasElement);
|
|
108
109
|
await userEvent.click(canvas.getByRole('button'));
|
|
109
110
|
|
|
111
|
+
const portalRoot = await waitFor(() => {
|
|
112
|
+
const root = document.querySelector('[data-portal-root]') as HTMLDivElement;
|
|
113
|
+
expect(root).toBeTruthy();
|
|
114
|
+
return root;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await expect(portalRoot).toBeVisible();
|
|
118
|
+
|
|
110
119
|
const modal = document.querySelector(
|
|
111
120
|
'.dropin-modal__body'
|
|
112
121
|
) as HTMLDivElement;
|
|
113
122
|
|
|
114
123
|
await expect(modal).toBeVisible();
|
|
115
124
|
|
|
116
|
-
|
|
125
|
+
expect(portalRoot.querySelector('h4')?.innerText).toBe('Small modal');
|
|
117
126
|
|
|
118
127
|
const closeButton = document.querySelector(
|
|
119
128
|
'.dropin-modal__header-close-button'
|
|
@@ -148,13 +157,21 @@ export const MediumModal: Story = {
|
|
|
148
157
|
const canvas = within(canvasElement);
|
|
149
158
|
await userEvent.click(canvas.getByRole('button'));
|
|
150
159
|
|
|
160
|
+
const portalRoot = await waitFor(() => {
|
|
161
|
+
const root = document.querySelector('[data-portal-root]') as HTMLDivElement;
|
|
162
|
+
expect(root).toBeTruthy();
|
|
163
|
+
return root;
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await expect(portalRoot).toBeVisible();
|
|
167
|
+
|
|
151
168
|
const modal = document.querySelector(
|
|
152
169
|
'.dropin-modal__body'
|
|
153
170
|
) as HTMLDivElement;
|
|
154
171
|
|
|
155
172
|
await expect(modal).toBeVisible();
|
|
156
173
|
|
|
157
|
-
|
|
174
|
+
expect(portalRoot.querySelector('h3')?.innerText).toBe('Medium modal');
|
|
158
175
|
|
|
159
176
|
const closeButton = document.querySelector(
|
|
160
177
|
'.dropin-modal__header-close-button'
|
|
@@ -187,11 +204,17 @@ export const FullModal: Story = {
|
|
|
187
204
|
const canvas = within(canvasElement);
|
|
188
205
|
await userEvent.click(canvas.getByRole('button'));
|
|
189
206
|
|
|
207
|
+
const portalRoot = await waitFor(() => {
|
|
208
|
+
const root = document.querySelector('[data-portal-root]') as HTMLDivElement;
|
|
209
|
+
expect(root).toBeTruthy();
|
|
210
|
+
return root;
|
|
211
|
+
});
|
|
212
|
+
|
|
190
213
|
const modal = document.querySelector(
|
|
191
214
|
'.dropin-modal__body'
|
|
192
215
|
) as HTMLDivElement;
|
|
193
216
|
|
|
194
|
-
await expect(
|
|
217
|
+
await expect(portalRoot).toBeVisible();
|
|
195
218
|
|
|
196
219
|
const closeButton = document.querySelector(
|
|
197
220
|
'.dropin-modal__header-close-button'
|
|
@@ -247,4 +270,65 @@ export const OverflowingTitle: Story = {
|
|
|
247
270
|
},
|
|
248
271
|
};
|
|
249
272
|
|
|
273
|
+
const LocalizedContent = () => {
|
|
274
|
+
const translations = useText({
|
|
275
|
+
label: 'Dropin.ExampleComponentName.item.label',
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
console.log(translations.label);
|
|
279
|
+
return <div>{translations.label}</div>;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* ```ts
|
|
284
|
+
* import { Modal } from '@/elsie/components/Modal';
|
|
285
|
+
* import { useText } from '@adobe-commerce/elsie/i18n';
|
|
286
|
+
*
|
|
287
|
+
* const label = useText(`Dropin.ExampleComponentName.item.label`).label;
|
|
288
|
+
*
|
|
289
|
+
* <Modal size="medium" title={<h3>Localized Modal</h3>}>
|
|
290
|
+
* <div>{label}</div>
|
|
291
|
+
* </Modal>
|
|
292
|
+
* ```
|
|
293
|
+
*/
|
|
294
|
+
|
|
295
|
+
export const LocalizedModal: Story = {
|
|
296
|
+
args: {
|
|
297
|
+
size: 'medium',
|
|
298
|
+
children: <LocalizedContent />,
|
|
299
|
+
title: <h3>Localized Modal</h3>,
|
|
300
|
+
},
|
|
301
|
+
play: async ({ canvasElement }) => {
|
|
302
|
+
const canvas = within(canvasElement);
|
|
303
|
+
await userEvent.click(canvas.getByRole('button'));
|
|
304
|
+
|
|
305
|
+
const portalRoot = await waitFor(() => {
|
|
306
|
+
const root = document.querySelector('[data-portal-root]') as HTMLDivElement;
|
|
307
|
+
expect(root).toBeTruthy();
|
|
308
|
+
return root;
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
await expect(portalRoot).toBeVisible();
|
|
312
|
+
|
|
313
|
+
const modal = document.querySelector(
|
|
314
|
+
'.dropin-modal__body'
|
|
315
|
+
) as HTMLDivElement;
|
|
316
|
+
|
|
317
|
+
await expect(modal).toBeVisible();
|
|
318
|
+
|
|
319
|
+
expect(portalRoot.querySelector('h3')?.innerText).toBe('Localized Modal');
|
|
320
|
+
expect((portalRoot.querySelector('.dropin-modal__body') as HTMLElement)?.innerText).toContain('string');
|
|
321
|
+
|
|
322
|
+
const closeButton = document.querySelector(
|
|
323
|
+
'.dropin-modal__header-close-button'
|
|
324
|
+
) as HTMLButtonElement;
|
|
325
|
+
|
|
326
|
+
await userEvent.click(closeButton);
|
|
327
|
+
|
|
328
|
+
await expect(modal).not.toBeVisible();
|
|
329
|
+
|
|
330
|
+
await expect(canvas.getByText('Open Modal')).toBeVisible();
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
|
|
250
334
|
export default meta;
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import { Button } from '../Button';
|
|
19
19
|
import { Close as CloseSVG } from '@adobe-commerce/elsie/icons';
|
|
20
20
|
import { VNode } from 'preact';
|
|
21
|
+
import { Portal } from '../Portal/Portal';
|
|
21
22
|
|
|
22
23
|
import '@adobe-commerce/elsie/components/Modal/Modal.css';
|
|
23
24
|
|
|
@@ -106,52 +107,54 @@ export const Modal: FunctionComponent<ModalProps> = ({
|
|
|
106
107
|
}, []);
|
|
107
108
|
|
|
108
109
|
return (
|
|
109
|
-
<
|
|
110
|
-
className={classes([
|
|
111
|
-
'dropin-modal',
|
|
112
|
-
['dropin-modal--dim', backgroundDim],
|
|
113
|
-
])}
|
|
114
|
-
>
|
|
110
|
+
<Portal>
|
|
115
111
|
<div
|
|
116
|
-
{...props}
|
|
117
112
|
className={classes([
|
|
118
|
-
'dropin-
|
|
119
|
-
[
|
|
120
|
-
className,
|
|
113
|
+
'dropin-modal',
|
|
114
|
+
['dropin-modal--dim', backgroundDim],
|
|
121
115
|
])}
|
|
122
116
|
>
|
|
123
117
|
<div
|
|
118
|
+
{...props}
|
|
124
119
|
className={classes([
|
|
125
|
-
'dropin-
|
|
126
|
-
[
|
|
120
|
+
'dropin-modal__body',
|
|
121
|
+
[`dropin-modal__body--${size}`, size],
|
|
122
|
+
className,
|
|
127
123
|
])}
|
|
128
124
|
>
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
125
|
+
<div
|
|
126
|
+
className={classes([
|
|
127
|
+
'dropin-modal__header',
|
|
128
|
+
['dropin-modal__header-title', !!title],
|
|
129
|
+
])}
|
|
130
|
+
>
|
|
131
|
+
{title && (
|
|
132
|
+
<div className={classes(['dropin-modal__header-title-content'])}>
|
|
133
|
+
{title}
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
|
|
137
|
+
{showCloseButton && (
|
|
138
|
+
<Button
|
|
139
|
+
aria-label={translations.modalCloseLabel}
|
|
140
|
+
variant="tertiary"
|
|
141
|
+
className="dropin-modal__header-close-button"
|
|
142
|
+
onClick={handleOnClose}
|
|
143
|
+
icon={<CloseSVG />}
|
|
144
|
+
/>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<div
|
|
149
|
+
className={classes([
|
|
150
|
+
'dropin-modal__content',
|
|
151
|
+
['dropin-modal__body--centered', centered],
|
|
152
|
+
])}
|
|
153
|
+
>
|
|
154
|
+
{children}
|
|
155
|
+
</div>
|
|
153
156
|
</div>
|
|
154
157
|
</div>
|
|
155
|
-
</
|
|
158
|
+
</Portal>
|
|
156
159
|
);
|
|
157
160
|
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/********************************************************************
|
|
2
|
+
* Copyright 2024 Adobe
|
|
3
|
+
* All Rights Reserved.
|
|
4
|
+
*
|
|
5
|
+
* NOTICE: Adobe permits you to use, modify, and distribute this
|
|
6
|
+
* file in accordance with the terms of the Adobe license agreement
|
|
7
|
+
* accompanying it.
|
|
8
|
+
*******************************************************************/
|
|
9
|
+
|
|
10
|
+
import { Meta, StoryObj } from '@storybook/preact';
|
|
11
|
+
import { Portal } from './Portal';
|
|
12
|
+
import { Button } from '../Button';
|
|
13
|
+
import { useState } from 'preact/compat';
|
|
14
|
+
|
|
15
|
+
const meta: Meta<typeof Portal> = {
|
|
16
|
+
title: 'Components/Portal',
|
|
17
|
+
component: Portal,
|
|
18
|
+
parameters: {
|
|
19
|
+
layout: 'centered',
|
|
20
|
+
},
|
|
21
|
+
tags: ['autodocs'],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export default meta;
|
|
25
|
+
type Story = StoryObj<typeof Portal>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* ```ts
|
|
29
|
+
* import { Portal } from '@/elsie/components/Portal';
|
|
30
|
+
*
|
|
31
|
+
* <Portal>
|
|
32
|
+
* <div>👋 Howdy, I'm Howdy!</div>
|
|
33
|
+
* </Portal>
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
// Portal with dynamic content
|
|
38
|
+
export const DynamicContent: Story = {
|
|
39
|
+
render: () => {
|
|
40
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
41
|
+
const [count, setCount] = useState(0);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div style={{ border: '2px dashed #ccc', padding: '20px' }}>
|
|
45
|
+
<Button onClick={() => setIsOpen(!isOpen)}>
|
|
46
|
+
{isOpen ? 'Close Portal' : 'Open Portal'}
|
|
47
|
+
</Button>
|
|
48
|
+
|
|
49
|
+
{isOpen && (
|
|
50
|
+
<Portal>
|
|
51
|
+
<div style={{
|
|
52
|
+
position: 'fixed',
|
|
53
|
+
top: '50%',
|
|
54
|
+
left: '50%',
|
|
55
|
+
transform: 'translate(-50%, -50%)',
|
|
56
|
+
background: 'white',
|
|
57
|
+
padding: '20px',
|
|
58
|
+
border: '1px solid #ccc',
|
|
59
|
+
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
|
60
|
+
}}>
|
|
61
|
+
<p>Portal content with counter: {count}</p>
|
|
62
|
+
<Button onClick={() => setCount(c => c + 1)}>
|
|
63
|
+
Increment Counter
|
|
64
|
+
</Button>
|
|
65
|
+
<Button
|
|
66
|
+
variant="tertiary"
|
|
67
|
+
onClick={() => setIsOpen(false)}
|
|
68
|
+
style={{ marginLeft: '8px' }}
|
|
69
|
+
>
|
|
70
|
+
Close
|
|
71
|
+
</Button>
|
|
72
|
+
</div>
|
|
73
|
+
</Portal>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Portal with nested portals
|
|
81
|
+
export const NestedPortals: Story = {
|
|
82
|
+
render: () => {
|
|
83
|
+
const [isOuterOpen, setOuterOpen] = useState(false);
|
|
84
|
+
const [isInnerOpen, setInnerOpen] = useState(false);
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div style={{ border: '2px dashed #ccc', padding: '20px' }}>
|
|
88
|
+
<Button onClick={() => setOuterOpen(!isOuterOpen)}>
|
|
89
|
+
{isOuterOpen ? 'Close Outer Portal' : 'Open Outer Portal'}
|
|
90
|
+
</Button>
|
|
91
|
+
|
|
92
|
+
{isOuterOpen && (
|
|
93
|
+
<Portal>
|
|
94
|
+
<div style={{
|
|
95
|
+
position: 'fixed',
|
|
96
|
+
top: '50%',
|
|
97
|
+
left: '50%',
|
|
98
|
+
transform: 'translate(-50%, -50%)',
|
|
99
|
+
background: 'white',
|
|
100
|
+
padding: '20px',
|
|
101
|
+
border: '1px solid #ccc',
|
|
102
|
+
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
|
103
|
+
}}>
|
|
104
|
+
<p>Outer Portal Content</p>
|
|
105
|
+
<Button onClick={() => setInnerOpen(!isInnerOpen)}>
|
|
106
|
+
{isInnerOpen ? 'Close Inner Portal' : 'Open Inner Portal'}
|
|
107
|
+
</Button>
|
|
108
|
+
|
|
109
|
+
{isInnerOpen && (
|
|
110
|
+
<Portal>
|
|
111
|
+
<div style={{
|
|
112
|
+
position: 'fixed',
|
|
113
|
+
top: '60%',
|
|
114
|
+
left: '50%',
|
|
115
|
+
transform: 'translate(-50%, -50%)',
|
|
116
|
+
background: 'white',
|
|
117
|
+
padding: '20px',
|
|
118
|
+
border: '1px solid #ccc',
|
|
119
|
+
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
|
120
|
+
}}>
|
|
121
|
+
<p>Inner Portal Content</p>
|
|
122
|
+
<Button
|
|
123
|
+
variant="tertiary"
|
|
124
|
+
onClick={() => setInnerOpen(false)}
|
|
125
|
+
>
|
|
126
|
+
Close Inner Portal
|
|
127
|
+
</Button>
|
|
128
|
+
</div>
|
|
129
|
+
</Portal>
|
|
130
|
+
)}
|
|
131
|
+
|
|
132
|
+
<Button
|
|
133
|
+
variant="tertiary"
|
|
134
|
+
onClick={() => setOuterOpen(false)}
|
|
135
|
+
style={{ marginLeft: '8px' }}
|
|
136
|
+
>
|
|
137
|
+
Close Outer Portal
|
|
138
|
+
</Button>
|
|
139
|
+
</div>
|
|
140
|
+
</Portal>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
},
|
|
145
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/********************************************************************
|
|
2
|
+
* Copyright 2024 Adobe
|
|
3
|
+
* All Rights Reserved.
|
|
4
|
+
*
|
|
5
|
+
* NOTICE: Adobe permits you to use, modify, and distribute this
|
|
6
|
+
* file in accordance with the terms of the Adobe license agreement
|
|
7
|
+
* accompanying it.
|
|
8
|
+
*******************************************************************/
|
|
9
|
+
|
|
10
|
+
import { ComponentChildren } from 'preact';
|
|
11
|
+
import { FunctionComponent, useLayoutEffect, useRef } from 'preact/compat';
|
|
12
|
+
|
|
13
|
+
interface PortalProps {
|
|
14
|
+
children: ComponentChildren;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const Portal: FunctionComponent<PortalProps> = ({ children }) => {
|
|
18
|
+
const portalRoot = useRef<HTMLDivElement | null>(null);
|
|
19
|
+
const contentRef = useRef<HTMLDivElement | null>(null);
|
|
20
|
+
|
|
21
|
+
useLayoutEffect(() => {
|
|
22
|
+
// Create portal root if it doesn't exist
|
|
23
|
+
if (!portalRoot.current) {
|
|
24
|
+
portalRoot.current = document.createElement('div');
|
|
25
|
+
portalRoot.current.setAttribute('data-portal-root', '');
|
|
26
|
+
document.body.appendChild(portalRoot.current);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Move content to portal root
|
|
30
|
+
if (contentRef.current && portalRoot.current) {
|
|
31
|
+
portalRoot.current.appendChild(contentRef.current);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Cleanup
|
|
35
|
+
return () => {
|
|
36
|
+
if (portalRoot.current) {
|
|
37
|
+
portalRoot.current.remove();
|
|
38
|
+
portalRoot.current = null;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
// Return a div that contains the children
|
|
44
|
+
return (
|
|
45
|
+
<div ref={contentRef} className="dropin-design">
|
|
46
|
+
{children}
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-edit-2"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path></svg>
|
package/src/icons/index.ts
CHANGED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import {
|
|
2
|
+
provider as UI,
|
|
3
|
+
Image,
|
|
4
|
+
type ImageProps,
|
|
5
|
+
} from '@adobe-commerce/elsie/components';
|
|
6
|
+
|
|
7
|
+
import { getConfigValue } from '@adobe-commerce/elsie/lib/aem/configs';
|
|
8
|
+
import type { ResolveImageUrlOptions } from '../resolve-image';
|
|
9
|
+
|
|
10
|
+
const AEM_ASSETS_FORMATS = ['gif', 'jpg', 'jpeg', 'png', 'webp'] as const;
|
|
11
|
+
const AEM_ASSETS_ALLOWED_ROTATIONS = [90, 180, 270] as const;
|
|
12
|
+
const AEM_ASSETS_ALLOWED_FLIPS = ['h', 'v', 'hv'] as const;
|
|
13
|
+
|
|
14
|
+
/** The allowed formats for the `AEM Assets` image optimization API. */
|
|
15
|
+
export type AemAssetsFormat = (typeof AEM_ASSETS_FORMATS)[number];
|
|
16
|
+
|
|
17
|
+
/** The allowed rotations for the `AEM Assets` image optimization API. */
|
|
18
|
+
export type AemAssetsRotation = (typeof AEM_ASSETS_ALLOWED_ROTATIONS)[number];
|
|
19
|
+
|
|
20
|
+
/** The allowed flips for the `AEM Assets` image optimization API. */
|
|
21
|
+
export type AemAssetsFlip = (typeof AEM_ASSETS_ALLOWED_FLIPS)[number];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Defines a crop region of an image.
|
|
25
|
+
* @example
|
|
26
|
+
* ```ts
|
|
27
|
+
* // Crop the image to a 80% width and height, starting at 10% from the top and left.
|
|
28
|
+
* const cropSettings: AemAssetsCropSettings = {
|
|
29
|
+
* xOrigin: 10,
|
|
30
|
+
* yOrigin: 10,
|
|
31
|
+
* width: 80,
|
|
32
|
+
* height: 80,
|
|
33
|
+
* };
|
|
34
|
+
*/
|
|
35
|
+
export interface AemAssetsCropSettings {
|
|
36
|
+
/** The (relative) x origin of the crop (between 0 and 100) */
|
|
37
|
+
xOrigin?: number;
|
|
38
|
+
|
|
39
|
+
/** The (relative) y origin of the crop (between 0 and 100) */
|
|
40
|
+
yOrigin?: number;
|
|
41
|
+
|
|
42
|
+
/** The width of the crop (between 0 and 100) */
|
|
43
|
+
width?: number;
|
|
44
|
+
|
|
45
|
+
/** The height of the crop (between 0 and 100) */
|
|
46
|
+
height?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The parameters accepted by the `AEM Assets` image optimization API.
|
|
51
|
+
* @see https://adobe-aem-assets-delivery-experimental.redoc.ly/
|
|
52
|
+
*/
|
|
53
|
+
export interface AemAssetsParams {
|
|
54
|
+
format: AemAssetsFormat;
|
|
55
|
+
rotate?: AemAssetsRotation;
|
|
56
|
+
flip?: AemAssetsFlip;
|
|
57
|
+
crop?: AemAssetsCropSettings;
|
|
58
|
+
|
|
59
|
+
width?: number;
|
|
60
|
+
height?: number;
|
|
61
|
+
quality?: number;
|
|
62
|
+
|
|
63
|
+
attachment?: boolean;
|
|
64
|
+
sharpen?: boolean;
|
|
65
|
+
blur?: number;
|
|
66
|
+
dpr?: number;
|
|
67
|
+
smartCrop?: string;
|
|
68
|
+
|
|
69
|
+
// For future updates we may miss.
|
|
70
|
+
[key: string]: unknown;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
|
|
74
|
+
|
|
75
|
+
/** The parameters to be applied to the asset (known width required when using a slot) */
|
|
76
|
+
export type AemAssetsImageSlotConfigParams = WithRequired<
|
|
77
|
+
Partial<AemAssetsParams>,
|
|
78
|
+
'width'
|
|
79
|
+
>;
|
|
80
|
+
|
|
81
|
+
/** The configuration for an image slot. */
|
|
82
|
+
export interface AemAssetsImageSlotConfig {
|
|
83
|
+
/** The alias (i.e. seoName) of the image */
|
|
84
|
+
alias: string;
|
|
85
|
+
|
|
86
|
+
/** The props to be applied to the underlying {@link Image} component */
|
|
87
|
+
imageProps: Partial<Omit<ImageProps, 'params' | 'width' | 'height'>> & {
|
|
88
|
+
src: string;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** The parameters to be applied to the asset (known width required when using a slot) */
|
|
92
|
+
params: AemAssetsImageSlotConfigParams;
|
|
93
|
+
|
|
94
|
+
/** The element that will contain the image in the slot */
|
|
95
|
+
wrapper?: HTMLElement;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface RenderContext {
|
|
99
|
+
replaceWith: (element: HTMLElement) => void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Normalizes the given URL to ensure it is a valid URL.
|
|
104
|
+
* @param {string} url - The URL to normalize.
|
|
105
|
+
* @returns {string} The normalized URL.
|
|
106
|
+
*/
|
|
107
|
+
function normalizeUrl(url: string): string {
|
|
108
|
+
let imageUrl = url;
|
|
109
|
+
|
|
110
|
+
if (imageUrl.startsWith('//')) {
|
|
111
|
+
// Use current window's protocol.
|
|
112
|
+
const { protocol } = window.location;
|
|
113
|
+
imageUrl = protocol + imageUrl;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return imageUrl;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Returns whether the given value is a valid flip. */
|
|
120
|
+
function isValidFlip(flip: unknown): flip is AemAssetsFlip {
|
|
121
|
+
return AEM_ASSETS_ALLOWED_FLIPS.includes(flip as AemAssetsFlip);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Returns whether the given value is a valid rotation. */
|
|
125
|
+
function isValidRotation(rotation: unknown): rotation is AemAssetsRotation {
|
|
126
|
+
return AEM_ASSETS_ALLOWED_ROTATIONS.includes(rotation as AemAssetsRotation);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Returns whether the given value is a valid format. */
|
|
130
|
+
function isValidFormat(format: unknown): format is AemAssetsFormat {
|
|
131
|
+
return AEM_ASSETS_FORMATS.includes(format as AemAssetsFormat);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Asserts that the given value is valid. */
|
|
135
|
+
function assertUnionParameter(
|
|
136
|
+
value: unknown,
|
|
137
|
+
validator: (value: unknown) => boolean,
|
|
138
|
+
errorMessage: string
|
|
139
|
+
): void {
|
|
140
|
+
if (value !== undefined && !validator(value)) {
|
|
141
|
+
throw new Error(errorMessage);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Returns whether AEM Assets is enabled in the Storefront. */
|
|
146
|
+
export function isAemAssetsEnabled(): boolean {
|
|
147
|
+
const config = getConfigValue('commerce-assets-enabled');
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
config &&
|
|
151
|
+
((typeof config === 'string' && config.toLowerCase() === 'true') ||
|
|
152
|
+
(typeof config === 'boolean' && config === true))
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The default optimization parameters used globally, unless overriden (per use). */
|
|
157
|
+
export function getDefaultAemAssetsOptimizationParams(): AemAssetsParams {
|
|
158
|
+
// See: https://adobe-aem-assets-delivery-experimental.redoc.ly/
|
|
159
|
+
return {
|
|
160
|
+
quality: 80,
|
|
161
|
+
format: 'webp',
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Returns true if the given URL is an AEM Assets URL. */
|
|
166
|
+
export function isAemAssetsUrl(url: string | URL): boolean {
|
|
167
|
+
const assetsUrl = typeof url === 'string' ? new URL(normalizeUrl(url)) : url;
|
|
168
|
+
|
|
169
|
+
if (!assetsUrl.pathname.startsWith('/adobe/assets/urn:aaid:aem')) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Generates an optimized URL for AEM Assets. */
|
|
177
|
+
export function generateAemAssetsOptimizedUrl(
|
|
178
|
+
assetUrl: string,
|
|
179
|
+
alias: string,
|
|
180
|
+
params: Partial<AemAssetsParams> = {}
|
|
181
|
+
): string {
|
|
182
|
+
const defaultParams = getDefaultAemAssetsOptimizationParams();
|
|
183
|
+
const mergedParams: AemAssetsParams = { ...defaultParams, ...params };
|
|
184
|
+
|
|
185
|
+
// Destructure the ones that need special handling/validation.
|
|
186
|
+
const { format, crop, ...optimizedParams } = mergedParams;
|
|
187
|
+
assertUnionParameter(format, isValidFormat, 'Invalid format');
|
|
188
|
+
assertUnionParameter(optimizedParams.flip, isValidFlip, 'Invalid flip');
|
|
189
|
+
assertUnionParameter(
|
|
190
|
+
optimizedParams.rotate,
|
|
191
|
+
isValidRotation,
|
|
192
|
+
'Invalid rotation'
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const stringifiedParams = Object.fromEntries(
|
|
196
|
+
Object.entries(optimizedParams).map(([key, value]) => [key, String(value)])
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const searchParams = new URLSearchParams(stringifiedParams);
|
|
200
|
+
|
|
201
|
+
if (crop) {
|
|
202
|
+
const [xOrigin, yOrigin] = [crop.xOrigin || 0, crop.yOrigin || 0];
|
|
203
|
+
const [width, height] = [crop.width || 100, crop.height || 100];
|
|
204
|
+
|
|
205
|
+
const cropTransform = `${xOrigin}p,${yOrigin}p,${width}p,${height}p`;
|
|
206
|
+
searchParams.set('crop', cropTransform);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return `${assetUrl}/as/${alias}.${format}?${searchParams.toString()}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Tries to generate an optimized URL for AEM Assets. Returns the given
|
|
214
|
+
* url if AEM Assets is not enabled or is not an AEM Assets URL.
|
|
215
|
+
*/
|
|
216
|
+
export function tryGenerateAemAssetsOptimizedUrl(
|
|
217
|
+
assetUrl: string,
|
|
218
|
+
alias: string,
|
|
219
|
+
params: Partial<AemAssetsParams> = {}
|
|
220
|
+
): string {
|
|
221
|
+
const assetsEnabled = isAemAssetsEnabled();
|
|
222
|
+
|
|
223
|
+
if (!assetsEnabled) {
|
|
224
|
+
// No-op, doesn't do anything.
|
|
225
|
+
return assetUrl;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const assetsUrl = new URL(normalizeUrl(assetUrl));
|
|
229
|
+
|
|
230
|
+
if (!isAemAssetsUrl(assetsUrl)) {
|
|
231
|
+
// Not an AEM Assets URL, so no-op.
|
|
232
|
+
return assetUrl;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const base = assetsUrl.origin + assetsUrl.pathname;
|
|
236
|
+
return generateAemAssetsOptimizedUrl(base, alias, params);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Creates a slot that renders an AEM Assets image. */
|
|
240
|
+
export function makeAemAssetsImageSlot(config: AemAssetsImageSlotConfig) {
|
|
241
|
+
return (ctx: RenderContext) => {
|
|
242
|
+
const { wrapper, alias, params, imageProps } = config;
|
|
243
|
+
|
|
244
|
+
if (!imageProps.src) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
'An image source is required. Please provide a `src` or `imageProps.src`.'
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const container = wrapper ?? document.createElement('div');
|
|
251
|
+
const imageSrc = generateAemAssetsOptimizedUrl(
|
|
252
|
+
imageProps.src,
|
|
253
|
+
alias,
|
|
254
|
+
params
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const imageComponentParams: ResolveImageUrlOptions = {
|
|
258
|
+
width: params.width,
|
|
259
|
+
height: params.height,
|
|
260
|
+
|
|
261
|
+
// If this is not done, they will be applied by default.
|
|
262
|
+
// And they are not compatible with the AEM Assets API.
|
|
263
|
+
crop: undefined,
|
|
264
|
+
fit: undefined,
|
|
265
|
+
auto: undefined,
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const imageComponentProps: ImageProps = {
|
|
269
|
+
...imageProps,
|
|
270
|
+
width: params.width,
|
|
271
|
+
height: params.height,
|
|
272
|
+
|
|
273
|
+
src: imageSrc,
|
|
274
|
+
params: imageComponentParams,
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
UI.render(Image, imageComponentProps)(container);
|
|
278
|
+
ctx.replaceWith(container);
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function tryRenderAemAssetsImage(
|
|
283
|
+
ctx: RenderContext,
|
|
284
|
+
config: AemAssetsImageSlotConfig
|
|
285
|
+
): void {
|
|
286
|
+
// Renders an equivalent of the default image.
|
|
287
|
+
function renderDefaultImage(): void {
|
|
288
|
+
const container = config.wrapper ?? document.createElement('div');
|
|
289
|
+
const { imageProps, params } = config;
|
|
290
|
+
const imageComponentProps: ImageProps = {
|
|
291
|
+
...imageProps,
|
|
292
|
+
width: params.width,
|
|
293
|
+
height: params.height,
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
UI.render(Image, imageComponentProps)(container);
|
|
297
|
+
ctx.replaceWith(container);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const assetsEnabled = isAemAssetsEnabled();
|
|
301
|
+
|
|
302
|
+
if (!assetsEnabled) {
|
|
303
|
+
// No-op, render the default image.
|
|
304
|
+
renderDefaultImage();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (!config.imageProps.src) {
|
|
309
|
+
throw new Error(
|
|
310
|
+
'An image source is required. Please provide a `src` or `imageProps.src`.'
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const assetsUrl = new URL(normalizeUrl(config.imageProps.src));
|
|
315
|
+
|
|
316
|
+
if (!isAemAssetsUrl(assetsUrl)) {
|
|
317
|
+
// Not an AEM Assets URL, so render the default image.
|
|
318
|
+
renderDefaultImage();
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
makeAemAssetsImageSlot(config)(ctx);
|
|
323
|
+
}
|