@adobe-commerce/elsie 1.3.1-alpha013 → 1.3.1-alpha014
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/Icon/Icon.stories.tsx +96 -7
- package/src/components/Icon/Icon.tsx +131 -4
- package/src/components/Modal/Modal.stories.tsx +0 -62
- package/src/components/Portal/Portal.tsx +9 -15
- package/src/lib/aem/assets.ts +102 -218
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adobe-commerce/elsie",
|
|
3
|
-
"version": "1.3.1-
|
|
3
|
+
"version": "1.3.1-alpha014",
|
|
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.1.0
|
|
31
|
-
"@adobe-commerce/recaptcha": "1.0.1
|
|
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.1
|
|
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
|
-
|
|
59
|
-
a.dropin-button
|
|
58
|
+
.dropin-button--primary,
|
|
59
|
+
a.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.dropin-button--primary,
|
|
|
72
72
|
padding: var(--spacing-xsmall);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
a.dropin-button
|
|
75
|
+
.dropin-button--primary--disabled,
|
|
76
|
+
a.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.dropin-button--primary--disabled,
|
|
|
82
82
|
user-select: none;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
a.dropin-button
|
|
85
|
+
.dropin-button--primary:hover,
|
|
86
|
+
a.dropin-button--primary:hover,
|
|
87
87
|
.dropin-iconButton--primary:hover,
|
|
88
|
-
|
|
88
|
+
.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
|
-
|
|
94
|
+
.dropin-button--primary:focus,
|
|
95
95
|
.dropin-iconButton--primary:focus {
|
|
96
96
|
background-color: var(--color-brand-500);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
|
|
99
|
+
.dropin-button--primary:hover:active,
|
|
100
100
|
.dropin-iconButton--primary:hover:active {
|
|
101
101
|
background-color: var(--color-button-active);
|
|
102
102
|
}
|
|
@@ -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,23 +69,26 @@ 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
93
|
style: Object
|
|
74
94
|
},
|
|
@@ -96,3 +116,72 @@ export const AllIcons: Story = {
|
|
|
96
116
|
</div>
|
|
97
117
|
),
|
|
98
118
|
};
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
export const UrlExamples: Story = {
|
|
122
|
+
render: ({ url, ...args }: StoryIconProps) => (
|
|
123
|
+
<div style={{
|
|
124
|
+
display: 'grid',
|
|
125
|
+
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
|
126
|
+
gap: '2rem',
|
|
127
|
+
padding: '1rem'
|
|
128
|
+
}}>
|
|
129
|
+
<div style={{
|
|
130
|
+
border: '1px solid #e1e5e9',
|
|
131
|
+
borderRadius: '8px',
|
|
132
|
+
padding: '1.5rem',
|
|
133
|
+
textAlign: 'center',
|
|
134
|
+
backgroundColor: '#f8f9fa'
|
|
135
|
+
}}>
|
|
136
|
+
<h3 style={{ margin: '0 0 1rem 0', color: '#2c3e50' }}>✅ Valid URL</h3>
|
|
137
|
+
<Icon
|
|
138
|
+
source={url || `${window.location.origin}/favicon.svg`}
|
|
139
|
+
size="12"
|
|
140
|
+
title="logo icon from common domain"
|
|
141
|
+
aria-label="Star icon loaded from external URL"
|
|
142
|
+
{...args}
|
|
143
|
+
/>
|
|
144
|
+
<p style={{
|
|
145
|
+
fontSize: '12px',
|
|
146
|
+
color: '#495057',
|
|
147
|
+
margin: '0.5rem 0 0 0',
|
|
148
|
+
wordBreak: 'break-all'
|
|
149
|
+
}}>
|
|
150
|
+
{url ? `Displays icon from: ${url}` : 'Displays icon from SVG'}
|
|
151
|
+
</p>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<div style={{
|
|
155
|
+
border: '1px solid #f8d7da',
|
|
156
|
+
borderRadius: '8px',
|
|
157
|
+
padding: '1.5rem',
|
|
158
|
+
textAlign: 'center',
|
|
159
|
+
backgroundColor: '#f8d7da'
|
|
160
|
+
}}>
|
|
161
|
+
<h3 style={{ margin: '0 0 1rem 0', color: '#721c24' }}>❌ Invalid URL</h3>
|
|
162
|
+
<Icon
|
|
163
|
+
source="https://invalid-url.com/icon.svg"
|
|
164
|
+
size="32"
|
|
165
|
+
title="Failed to load icon"
|
|
166
|
+
aria-label="Icon that failed to load"
|
|
167
|
+
/>
|
|
168
|
+
<p style={{
|
|
169
|
+
fontSize: '12px',
|
|
170
|
+
color: '#721c24',
|
|
171
|
+
margin: '0.5rem 0 0 0',
|
|
172
|
+
wordBreak: 'break-all'
|
|
173
|
+
}}>
|
|
174
|
+
Shows empty SVG
|
|
175
|
+
</p>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
</div>
|
|
179
|
+
),
|
|
180
|
+
parameters: {
|
|
181
|
+
docs: {
|
|
182
|
+
description: {
|
|
183
|
+
story: 'Examples of different URL formats supported by the Icon component.',
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
};
|
|
@@ -10,6 +10,7 @@
|
|
|
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
|
|
|
@@ -66,9 +67,10 @@ const lazyIcons = {
|
|
|
66
67
|
};
|
|
67
68
|
|
|
68
69
|
export interface IconProps extends Omit<SVGProps<SVGSVGElement>, 'size'> {
|
|
69
|
-
source
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
source?:
|
|
71
|
+
| FunctionComponent<SVGProps<SVGSVGElement> & { title?: string }>
|
|
72
|
+
| IconType
|
|
73
|
+
| string;
|
|
72
74
|
size?: '12' | '16' | '24' | '32' | '64' | '80';
|
|
73
75
|
stroke?: '1' | '2' | '3' | '4';
|
|
74
76
|
className?: string;
|
|
@@ -79,6 +81,115 @@ export type IconNode = FunctionComponent<
|
|
|
79
81
|
SVGProps<SVGSVGElement> & { title?: string }
|
|
80
82
|
>;
|
|
81
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
|
+
return url.hostname === window.location.hostname;
|
|
90
|
+
}
|
|
91
|
+
const url = new URL(source);
|
|
92
|
+
|
|
93
|
+
if (url.hostname !== window.location.hostname) {
|
|
94
|
+
console.error(`[Icon] External URL rejected for security: ${source} - Only same-domain URLs are allowed`);
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return true;
|
|
99
|
+
} catch {
|
|
100
|
+
console.error(`[Icon] Invalid URL format: ${source}`);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function UrlSvgLoader({
|
|
106
|
+
url,
|
|
107
|
+
...props
|
|
108
|
+
}: SVGProps<SVGSVGElement> & { url: string }) {
|
|
109
|
+
const [svgContent, setSvgContent] = useState<string>('');
|
|
110
|
+
const [loading, setLoading] = useState(true);
|
|
111
|
+
const [error, setError] = useState(false);
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
fetch(url)
|
|
115
|
+
.then(response => {
|
|
116
|
+
if (!response.ok) {
|
|
117
|
+
console.error(`[Icon] Failed to fetch SVG: ${response.status} ${response.statusText}`);
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
return response.text();
|
|
121
|
+
})
|
|
122
|
+
.then(content => {
|
|
123
|
+
// Check if content is valid SVG
|
|
124
|
+
if (!content.trim().toLowerCase().startsWith('<?xml') &&
|
|
125
|
+
!content.trim().toLowerCase().startsWith('<svg')) {
|
|
126
|
+
console.error(`[Icon] Invalid SVG content from ${url} - Content must be a valid SVG file`);
|
|
127
|
+
setError(true);
|
|
128
|
+
setLoading(false);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Process SVG content to ensure proper sizing and accessibility
|
|
133
|
+
let processedContent = content;
|
|
134
|
+
|
|
135
|
+
if (props.width) {
|
|
136
|
+
processedContent = processedContent.replace(
|
|
137
|
+
/<svg([^>]*)\s+width\s*=\s*["'][^"']*["']/gi,
|
|
138
|
+
'<svg$1'
|
|
139
|
+
);
|
|
140
|
+
processedContent = processedContent.replace(
|
|
141
|
+
/<svg/i,
|
|
142
|
+
`<svg width="${props.width}"`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (props.height) {
|
|
147
|
+
processedContent = processedContent.replace(
|
|
148
|
+
/<svg([^>]*)\s+height\s*=\s*["'][^"']*["']/gi,
|
|
149
|
+
'<svg$1'
|
|
150
|
+
);
|
|
151
|
+
processedContent = processedContent.replace(
|
|
152
|
+
/<svg/i,
|
|
153
|
+
`<svg height="${props.height}"`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (props.title) {
|
|
158
|
+
processedContent = processedContent.replace(/<title[^>]*>.*?<\/title>/gi, '');
|
|
159
|
+
processedContent = processedContent.replace(
|
|
160
|
+
/<svg([^>]*)>/i,
|
|
161
|
+
`<svg$1><title>${props.title}</title>`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
setSvgContent(processedContent);
|
|
166
|
+
setLoading(false);
|
|
167
|
+
})
|
|
168
|
+
.catch((error) => {
|
|
169
|
+
console.error(`[Icon] ${error.message}`);
|
|
170
|
+
setError(true);
|
|
171
|
+
setLoading(false);
|
|
172
|
+
});
|
|
173
|
+
}, [url, props.width, props.height, props.title]);
|
|
174
|
+
|
|
175
|
+
if (loading || error) {
|
|
176
|
+
return <svg {...props} />;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<div
|
|
181
|
+
className={props.className}
|
|
182
|
+
style={{
|
|
183
|
+
width: String(props.width),
|
|
184
|
+
height: String(props.height),
|
|
185
|
+
display: 'inline-block',
|
|
186
|
+
lineHeight: 0,
|
|
187
|
+
}}
|
|
188
|
+
dangerouslySetInnerHTML={{ __html: svgContent }}
|
|
189
|
+
/>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
82
193
|
export function Icon({
|
|
83
194
|
source: Source,
|
|
84
195
|
size = '24',
|
|
@@ -87,7 +198,6 @@ export function Icon({
|
|
|
87
198
|
className,
|
|
88
199
|
...props
|
|
89
200
|
}: IconProps) {
|
|
90
|
-
const LazyIcon = typeof Source === 'string' ? lazyIcons[Source] : null;
|
|
91
201
|
|
|
92
202
|
const defaultProps = {
|
|
93
203
|
className: classes([
|
|
@@ -100,10 +210,27 @@ export function Icon({
|
|
|
100
210
|
viewBox,
|
|
101
211
|
};
|
|
102
212
|
|
|
213
|
+
if (typeof Source === 'string' && isValidUrl(Source)) {
|
|
214
|
+
return (
|
|
215
|
+
<Suspense fallback={<svg {...props} {...defaultProps} />}>
|
|
216
|
+
<UrlSvgLoader url={Source} {...props} {...defaultProps}/>
|
|
217
|
+
</Suspense>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const LazyIcon = typeof Source === 'string' && Source in lazyIcons
|
|
222
|
+
? lazyIcons[Source as IconType]
|
|
223
|
+
: null;
|
|
224
|
+
|
|
225
|
+
const isRejectedUrl = typeof Source === 'string' &&
|
|
226
|
+
(Source.startsWith('http') || Source.startsWith('//') || Source.startsWith('/'));
|
|
227
|
+
|
|
103
228
|
return (
|
|
104
229
|
<Suspense fallback={<svg {...props} {...defaultProps} />}>
|
|
105
230
|
{LazyIcon ? (
|
|
106
231
|
<LazyIcon {...props} {...defaultProps} />
|
|
232
|
+
) : isRejectedUrl ? (
|
|
233
|
+
<svg {...props} {...defaultProps} />
|
|
107
234
|
) : (
|
|
108
235
|
// @ts-ignore
|
|
109
236
|
<Source {...props} {...defaultProps} />
|
|
@@ -12,7 +12,6 @@ import { Modal as component, ModalProps } from './Modal';
|
|
|
12
12
|
import { useState } from 'preact/hooks';
|
|
13
13
|
import { Button } from '../Button';
|
|
14
14
|
import { expect, userEvent, within, waitFor } from '@storybook/test';
|
|
15
|
-
import { useText } from '@adobe-commerce/elsie/i18n';
|
|
16
15
|
|
|
17
16
|
const meta: Meta<ModalProps> = {
|
|
18
17
|
title: 'Components/Modal',
|
|
@@ -270,65 +269,4 @@ export const OverflowingTitle: Story = {
|
|
|
270
269
|
},
|
|
271
270
|
};
|
|
272
271
|
|
|
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
|
-
|
|
334
272
|
export default meta;
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* accompanying it.
|
|
8
8
|
*******************************************************************/
|
|
9
9
|
|
|
10
|
-
import { ComponentChildren } from 'preact';
|
|
11
|
-
import { FunctionComponent,
|
|
10
|
+
import { ComponentChildren, render } from 'preact';
|
|
11
|
+
import { FunctionComponent, useEffect, useRef } from 'preact/compat';
|
|
12
12
|
|
|
13
13
|
interface PortalProps {
|
|
14
14
|
children: ComponentChildren;
|
|
@@ -16,34 +16,28 @@ interface PortalProps {
|
|
|
16
16
|
|
|
17
17
|
export const Portal: FunctionComponent<PortalProps> = ({ children }) => {
|
|
18
18
|
const portalRoot = useRef<HTMLDivElement | null>(null);
|
|
19
|
-
const contentRef = useRef<HTMLDivElement | null>(null);
|
|
20
19
|
|
|
21
|
-
|
|
20
|
+
useEffect(() => {
|
|
22
21
|
// Create portal root if it doesn't exist
|
|
23
22
|
if (!portalRoot.current) {
|
|
24
23
|
portalRoot.current = document.createElement('div');
|
|
25
24
|
portalRoot.current.setAttribute('data-portal-root', '');
|
|
25
|
+
portalRoot.current.classList.add('dropin-design');
|
|
26
26
|
document.body.appendChild(portalRoot.current);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
portalRoot.current.appendChild(contentRef.current);
|
|
32
|
-
}
|
|
29
|
+
// Render children into portal root
|
|
30
|
+
render(children, portalRoot.current);
|
|
33
31
|
|
|
34
32
|
// Cleanup
|
|
35
33
|
return () => {
|
|
36
34
|
if (portalRoot.current) {
|
|
35
|
+
render(null, portalRoot.current);
|
|
37
36
|
portalRoot.current.remove();
|
|
38
37
|
portalRoot.current = null;
|
|
39
38
|
}
|
|
40
39
|
};
|
|
41
|
-
}, []);
|
|
40
|
+
}, [children]);
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
return (
|
|
45
|
-
<div ref={contentRef} className="dropin-design">
|
|
46
|
-
{children}
|
|
47
|
-
</div>
|
|
48
|
-
);
|
|
42
|
+
return null;
|
|
49
43
|
};
|
package/src/lib/aem/assets.ts
CHANGED
|
@@ -1,104 +1,58 @@
|
|
|
1
|
-
import {
|
|
2
|
-
provider as UI,
|
|
3
|
-
Image,
|
|
4
|
-
type ImageProps,
|
|
5
|
-
} from '@adobe-commerce/elsie/components';
|
|
6
|
-
|
|
1
|
+
import { provider as UI, Image } from '@adobe-commerce/elsie/components';
|
|
7
2
|
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
3
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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) */
|
|
4
|
+
interface AemAssetsParams {
|
|
5
|
+
quality?: number;
|
|
6
|
+
format?: string;
|
|
7
|
+
crop?: {
|
|
8
|
+
xOrigin?: number;
|
|
9
|
+
yOrigin?: number;
|
|
10
|
+
width?: number;
|
|
11
|
+
height?: number;
|
|
12
|
+
};
|
|
13
|
+
size?: {
|
|
14
|
+
width?: number;
|
|
15
|
+
height?: number;
|
|
16
|
+
};
|
|
43
17
|
width?: number;
|
|
44
|
-
|
|
45
|
-
/** The height of the crop (between 0 and 100) */
|
|
46
18
|
height?: number;
|
|
19
|
+
[key: string]: any;
|
|
47
20
|
}
|
|
48
21
|
|
|
49
|
-
|
|
50
|
-
|
|
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 */
|
|
22
|
+
interface AemAssetsImageConfig {
|
|
23
|
+
wrapper?: HTMLElement;
|
|
84
24
|
alias: string;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
imageProps: Partial<Omit<ImageProps, 'params' | 'width' | 'height'>> & {
|
|
25
|
+
params: AemAssetsParams;
|
|
26
|
+
imageProps: {
|
|
88
27
|
src: string;
|
|
28
|
+
width?: number;
|
|
29
|
+
height?: number;
|
|
30
|
+
[key: string]: any;
|
|
89
31
|
};
|
|
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;
|
|
32
|
+
src?: string;
|
|
96
33
|
}
|
|
97
34
|
|
|
98
35
|
interface RenderContext {
|
|
99
36
|
replaceWith: (element: HTMLElement) => void;
|
|
100
37
|
}
|
|
101
38
|
|
|
39
|
+
export function isAemAssetsEnabled(): boolean {
|
|
40
|
+
const config = getConfigValue('commerce-assets-enabled');
|
|
41
|
+
|
|
42
|
+
return config && (
|
|
43
|
+
(typeof config === 'string' && config.toLowerCase() === 'true')
|
|
44
|
+
|| (typeof config === 'boolean' && config === true)
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getDefaultAemAssetsOptimizationParams(): { quality: number; format: string } {
|
|
49
|
+
// See: https://adobe-aem-assets-delivery-experimental.redoc.ly/
|
|
50
|
+
return {
|
|
51
|
+
quality: 80,
|
|
52
|
+
format: 'webp',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
102
56
|
/**
|
|
103
57
|
* Normalizes the given URL to ensure it is a valid URL.
|
|
104
58
|
* @param {string} url - The URL to normalize.
|
|
@@ -110,59 +64,13 @@ function normalizeUrl(url: string): string {
|
|
|
110
64
|
if (imageUrl.startsWith('//')) {
|
|
111
65
|
// Use current window's protocol.
|
|
112
66
|
const { protocol } = window.location;
|
|
67
|
+
console.log('protocol', protocol);
|
|
113
68
|
imageUrl = protocol + imageUrl;
|
|
114
69
|
}
|
|
115
70
|
|
|
116
71
|
return imageUrl;
|
|
117
72
|
}
|
|
118
73
|
|
|
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
74
|
export function isAemAssetsUrl(url: string | URL): boolean {
|
|
167
75
|
const assetsUrl = typeof url === 'string' ? new URL(normalizeUrl(url)) : url;
|
|
168
76
|
|
|
@@ -173,30 +81,19 @@ export function isAemAssetsUrl(url: string | URL): boolean {
|
|
|
173
81
|
return true;
|
|
174
82
|
}
|
|
175
83
|
|
|
176
|
-
|
|
177
|
-
export function generateAemAssetsOptimizedUrl(
|
|
178
|
-
assetUrl: string,
|
|
179
|
-
alias: string,
|
|
180
|
-
params: Partial<AemAssetsParams> = {}
|
|
181
|
-
): string {
|
|
84
|
+
export function generateAemAssetsOptimizedUrl(url: string, alias: string, params: AemAssetsParams = {}): string {
|
|
182
85
|
const defaultParams = getDefaultAemAssetsOptimizationParams();
|
|
183
86
|
const mergedParams: AemAssetsParams = { ...defaultParams, ...params };
|
|
184
87
|
|
|
185
|
-
// Destructure the ones that need special handling
|
|
186
|
-
const {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
optimizedParams
|
|
191
|
-
|
|
192
|
-
'Invalid rotation'
|
|
193
|
-
);
|
|
194
|
-
|
|
195
|
-
const stringifiedParams = Object.fromEntries(
|
|
196
|
-
Object.entries(optimizedParams).map(([key, value]) => [key, String(value)])
|
|
197
|
-
);
|
|
88
|
+
// Destructure the ones that need special handling
|
|
89
|
+
const {
|
|
90
|
+
format,
|
|
91
|
+
crop,
|
|
92
|
+
size,
|
|
93
|
+
...optimizedParams
|
|
94
|
+
} = mergedParams;
|
|
198
95
|
|
|
199
|
-
const searchParams = new URLSearchParams(
|
|
96
|
+
const searchParams = new URLSearchParams(optimizedParams);
|
|
200
97
|
|
|
201
98
|
if (crop) {
|
|
202
99
|
const [xOrigin, yOrigin] = [crop.xOrigin || 0, crop.yOrigin || 0];
|
|
@@ -206,112 +103,87 @@ export function generateAemAssetsOptimizedUrl(
|
|
|
206
103
|
searchParams.set('crop', cropTransform);
|
|
207
104
|
}
|
|
208
105
|
|
|
209
|
-
|
|
106
|
+
// Both values must be present
|
|
107
|
+
if (size && size.width && size.height) {
|
|
108
|
+
searchParams.set('size', `${size.width},${size.height}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return `${url}/as/${alias}.${format}?${searchParams.toString()}`;
|
|
210
112
|
}
|
|
211
113
|
|
|
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 {
|
|
114
|
+
export function tryGenerateAemAssetsOptimizedUrl(url: string, alias: string, params: AemAssetsParams = {}): string {
|
|
221
115
|
const assetsEnabled = isAemAssetsEnabled();
|
|
222
116
|
|
|
223
|
-
if (!assetsEnabled) {
|
|
117
|
+
if (!(assetsEnabled)) {
|
|
224
118
|
// No-op, doesn't do anything.
|
|
225
|
-
return
|
|
119
|
+
return url;
|
|
226
120
|
}
|
|
227
121
|
|
|
228
|
-
const assetsUrl = new URL(normalizeUrl(
|
|
122
|
+
const assetsUrl = new URL(normalizeUrl(url));
|
|
229
123
|
|
|
230
124
|
if (!isAemAssetsUrl(assetsUrl)) {
|
|
231
125
|
// Not an AEM Assets URL, so no-op.
|
|
232
|
-
return
|
|
126
|
+
return url;
|
|
233
127
|
}
|
|
234
128
|
|
|
235
129
|
const base = assetsUrl.origin + assetsUrl.pathname;
|
|
236
130
|
return generateAemAssetsOptimizedUrl(base, alias, params);
|
|
237
131
|
}
|
|
238
132
|
|
|
239
|
-
|
|
240
|
-
|
|
133
|
+
export function makeAemAssetsImageSlot(
|
|
134
|
+
config: AemAssetsImageConfig,
|
|
135
|
+
) {
|
|
241
136
|
return (ctx: RenderContext) => {
|
|
242
|
-
const {
|
|
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,
|
|
137
|
+
const {
|
|
138
|
+
wrapper,
|
|
253
139
|
alias,
|
|
254
|
-
params
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
width: params.width,
|
|
259
|
-
height: params.height,
|
|
140
|
+
params,
|
|
141
|
+
imageProps,
|
|
142
|
+
src,
|
|
143
|
+
} = config;
|
|
260
144
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
crop: undefined,
|
|
264
|
-
fit: undefined,
|
|
265
|
-
auto: undefined,
|
|
266
|
-
};
|
|
145
|
+
const container = wrapper ?? document.createElement('div');
|
|
146
|
+
const imageSrc = generateAemAssetsOptimizedUrl(src || imageProps.src, alias, params);
|
|
267
147
|
|
|
268
|
-
|
|
148
|
+
UI.render(Image as any, {
|
|
269
149
|
...imageProps,
|
|
270
|
-
width: params.width,
|
|
271
|
-
height: params.height,
|
|
272
150
|
|
|
273
151
|
src: imageSrc,
|
|
274
|
-
params:
|
|
275
|
-
|
|
152
|
+
params: {
|
|
153
|
+
width: params.width,
|
|
154
|
+
|
|
155
|
+
// If not null, they will be applied by default.
|
|
156
|
+
// And they are not compatible with the AEM Assets API.
|
|
157
|
+
crop: null,
|
|
158
|
+
fit: null,
|
|
159
|
+
auto: null,
|
|
160
|
+
},
|
|
161
|
+
})(container);
|
|
276
162
|
|
|
277
|
-
UI.render(Image, imageComponentProps)(container);
|
|
278
163
|
ctx.replaceWith(container);
|
|
279
164
|
};
|
|
280
165
|
}
|
|
281
166
|
|
|
282
|
-
export function tryRenderAemAssetsImage(
|
|
283
|
-
ctx: RenderContext,
|
|
284
|
-
config: AemAssetsImageSlotConfig
|
|
285
|
-
): void {
|
|
167
|
+
export function tryRenderAemAssetsImage(ctx: RenderContext, config: AemAssetsImageConfig): void {
|
|
286
168
|
// Renders an equivalent of the default image.
|
|
287
169
|
function renderDefaultImage(): void {
|
|
288
170
|
const container = config.wrapper ?? document.createElement('div');
|
|
289
|
-
const { imageProps
|
|
290
|
-
const imageComponentProps: ImageProps = {
|
|
291
|
-
...imageProps,
|
|
292
|
-
width: params.width,
|
|
293
|
-
height: params.height,
|
|
294
|
-
};
|
|
171
|
+
const { imageProps } = config;
|
|
295
172
|
|
|
296
|
-
UI.render(Image,
|
|
173
|
+
(UI.render as any)(Image, imageProps)(container);
|
|
297
174
|
ctx.replaceWith(container);
|
|
298
175
|
}
|
|
299
176
|
|
|
300
177
|
const assetsEnabled = isAemAssetsEnabled();
|
|
301
178
|
|
|
302
|
-
if (!assetsEnabled) {
|
|
179
|
+
if (!(assetsEnabled)) {
|
|
303
180
|
// No-op, render the default image.
|
|
304
181
|
renderDefaultImage();
|
|
305
182
|
return;
|
|
306
183
|
}
|
|
307
184
|
|
|
308
|
-
|
|
309
|
-
|
|
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));
|
|
185
|
+
const { imageProps, src, ...slotConfig } = config;
|
|
186
|
+
const assetsUrl = new URL(normalizeUrl(src ?? imageProps.src));
|
|
315
187
|
|
|
316
188
|
if (!isAemAssetsUrl(assetsUrl)) {
|
|
317
189
|
// Not an AEM Assets URL, so render the default image.
|
|
@@ -319,5 +191,17 @@ export function tryRenderAemAssetsImage(
|
|
|
319
191
|
return;
|
|
320
192
|
}
|
|
321
193
|
|
|
322
|
-
makeAemAssetsImageSlot(
|
|
194
|
+
makeAemAssetsImageSlot({
|
|
195
|
+
// Use the default image props for params and src.
|
|
196
|
+
// Unless overriden by the slot config.
|
|
197
|
+
src: assetsUrl.toString(),
|
|
198
|
+
params: {
|
|
199
|
+
width: imageProps.width,
|
|
200
|
+
height: imageProps.height,
|
|
201
|
+
...slotConfig.params,
|
|
202
|
+
},
|
|
203
|
+
imageProps,
|
|
204
|
+
alias: slotConfig.alias,
|
|
205
|
+
wrapper: slotConfig.wrapper,
|
|
206
|
+
})(ctx);
|
|
323
207
|
}
|