@adobe-commerce/elsie 1.3.1-alpha02 → 1.4.0-alpha1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe-commerce/elsie",
3
- "version": "1.3.1-alpha02",
3
+ "version": "1.4.0-alpha1",
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.0.0",
31
- "@adobe-commerce/recaptcha": "~1.0.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.0",
33
+ "@dropins/build-tools": "~1.0.1",
34
34
  "preact": "~10.22.1",
35
35
  "vite-plugin-banner": "^0.8.0"
36
36
  },
@@ -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<IconProps> = {
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<IconProps>;
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
- argTypes: {
62
- source: {
63
- mapping: Object.keys(Icons),
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 AllIcons: Story = {
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
+ };
@@ -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
 
@@ -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
- | FunctionComponent<SVGProps<SVGSVGElement> & { title?: string }>
70
- | IconType;
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,130 @@ 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
+ 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
+ try {
125
+ const parser = new DOMParser();
126
+ const doc = parser.parseFromString(content, "image/svg+xml");
127
+ const svg = doc.querySelector('svg');
128
+ if (!svg) {
129
+ throw new Error("No <svg> element found");
130
+ }
131
+ // Success!
132
+ } catch(e: unknown) {
133
+ if (e instanceof Error ) {
134
+ console.error(`[Icon] Invalid SVG content from ${url}: ${e.message}`);
135
+ } else {
136
+ console.error(`[Icon] Invalid SVG content from ${url}: ${String(e)}`);
137
+ }
138
+ setError(true);
139
+ setLoading(false);
140
+ }
141
+
142
+
143
+ // Process SVG content to ensure proper sizing and accessibility
144
+ let processedContent = content;
145
+
146
+ if (props.width) {
147
+ processedContent = processedContent.replace(
148
+ /<svg([^>]*)\s+width\s*=\s*["'][^"']*["']/gi,
149
+ '<svg$1'
150
+ );
151
+ processedContent = processedContent.replace(
152
+ /<svg/i,
153
+ `<svg width="${props.width}"`
154
+ );
155
+ }
156
+
157
+ if (props.height) {
158
+ processedContent = processedContent.replace(
159
+ /<svg([^>]*)\s+height\s*=\s*["'][^"']*["']/gi,
160
+ '<svg$1'
161
+ );
162
+ processedContent = processedContent.replace(
163
+ /<svg/i,
164
+ `<svg height="${props.height}"`
165
+ );
166
+ }
167
+
168
+ if (props.title) {
169
+ processedContent = processedContent.replace(/<title[^>]*>.*?<\/title>/gi, '');
170
+ processedContent = processedContent.replace(
171
+ /<svg([^>]*)>/i,
172
+ `<svg$1><title>${props.title}</title>`
173
+ );
174
+ }
175
+
176
+ setSvgContent(processedContent);
177
+ setLoading(false);
178
+ })
179
+ .catch((error) => {
180
+ if (error instanceof Error) {
181
+ console.error(`[Icon] ${error.message}`);
182
+ } else {
183
+ console.error(`[Icon] ${String(error)}`);
184
+ }
185
+ setError(true);
186
+ setLoading(false);
187
+ });
188
+ }, [url, props.width, props.height, props.title]);
189
+
190
+ if (loading || error) {
191
+ return <svg {...props} />;
192
+ }
193
+
194
+ return (
195
+ <span
196
+ className={props.className}
197
+ style={{
198
+ width: String(props.width),
199
+ height: String(props.height),
200
+ display: 'inline-flex',
201
+ lineHeight: 0,
202
+ }}
203
+ dangerouslySetInnerHTML={{ __html: svgContent }}
204
+ />
205
+ );
206
+ }
207
+
81
208
  export function Icon({
82
209
  source: Source,
83
210
  size = '24',
@@ -86,7 +213,6 @@ export function Icon({
86
213
  className,
87
214
  ...props
88
215
  }: IconProps) {
89
- const LazyIcon = typeof Source === 'string' ? lazyIcons[Source] : null;
90
216
 
91
217
  const defaultProps = {
92
218
  className: classes([
@@ -99,10 +225,27 @@ export function Icon({
99
225
  viewBox,
100
226
  };
101
227
 
228
+ if (typeof Source === 'string' && isValidUrl(Source)) {
229
+ return (
230
+ <Suspense fallback={<svg {...props} {...defaultProps} />}>
231
+ <UrlSvgLoader url={Source} {...props} {...defaultProps}/>
232
+ </Suspense>
233
+ );
234
+ }
235
+
236
+ const LazyIcon = typeof Source === 'string' && Source in lazyIcons
237
+ ? lazyIcons[Source as IconType]
238
+ : null;
239
+
240
+ const isRejectedUrl = typeof Source === 'string' &&
241
+ (Source.startsWith('http') || Source.startsWith('//') || Source.startsWith('/'));
242
+
102
243
  return (
103
244
  <Suspense fallback={<svg {...props} {...defaultProps} />}>
104
245
  {LazyIcon ? (
105
246
  <LazyIcon {...props} {...defaultProps} />
247
+ ) : isRejectedUrl ? (
248
+ <svg {...props} {...defaultProps} />
106
249
  ) : (
107
250
  // @ts-ignore
108
251
  <Source {...props} {...defaultProps} />
@@ -10,10 +10,10 @@
10
10
  /* https://cssguidelin.es/#bem-like-naming */
11
11
 
12
12
  .dropin-modal {
13
- height: 100vh;
14
- width: 100vw;
15
13
  position: fixed;
16
14
  top: 0;
15
+ right: 0;
16
+ bottom: 0;
17
17
  left: 0;
18
18
  z-index: 99999;
19
19
  overflow: auto;
@@ -11,7 +11,7 @@ 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
15
 
16
16
  const meta: Meta<ModalProps> = {
17
17
  title: 'Components/Modal',
@@ -107,13 +107,21 @@ export const SmallModal: Story = {
107
107
  const canvas = within(canvasElement);
108
108
  await userEvent.click(canvas.getByRole('button'));
109
109
 
110
+ const portalRoot = await waitFor(() => {
111
+ const root = document.querySelector('[data-portal-root]') as HTMLDivElement;
112
+ expect(root).toBeTruthy();
113
+ return root;
114
+ });
115
+
116
+ await expect(portalRoot).toBeVisible();
117
+
110
118
  const modal = document.querySelector(
111
119
  '.dropin-modal__body'
112
120
  ) as HTMLDivElement;
113
121
 
114
122
  await expect(modal).toBeVisible();
115
123
 
116
- await expect(await canvas.findByText('Small modal')).toBeVisible();
124
+ expect(portalRoot.querySelector('h4')?.innerText).toBe('Small modal');
117
125
 
118
126
  const closeButton = document.querySelector(
119
127
  '.dropin-modal__header-close-button'
@@ -148,13 +156,21 @@ export const MediumModal: Story = {
148
156
  const canvas = within(canvasElement);
149
157
  await userEvent.click(canvas.getByRole('button'));
150
158
 
159
+ const portalRoot = await waitFor(() => {
160
+ const root = document.querySelector('[data-portal-root]') as HTMLDivElement;
161
+ expect(root).toBeTruthy();
162
+ return root;
163
+ });
164
+
165
+ await expect(portalRoot).toBeVisible();
166
+
151
167
  const modal = document.querySelector(
152
168
  '.dropin-modal__body'
153
169
  ) as HTMLDivElement;
154
170
 
155
171
  await expect(modal).toBeVisible();
156
172
 
157
- await expect(await canvas.findByText('Medium modal')).toBeVisible();
173
+ expect(portalRoot.querySelector('h3')?.innerText).toBe('Medium modal');
158
174
 
159
175
  const closeButton = document.querySelector(
160
176
  '.dropin-modal__header-close-button'
@@ -187,11 +203,17 @@ export const FullModal: Story = {
187
203
  const canvas = within(canvasElement);
188
204
  await userEvent.click(canvas.getByRole('button'));
189
205
 
206
+ const portalRoot = await waitFor(() => {
207
+ const root = document.querySelector('[data-portal-root]') as HTMLDivElement;
208
+ expect(root).toBeTruthy();
209
+ return root;
210
+ });
211
+
190
212
  const modal = document.querySelector(
191
213
  '.dropin-modal__body'
192
214
  ) as HTMLDivElement;
193
215
 
194
- await expect(modal).toBeVisible();
216
+ await expect(portalRoot).toBeVisible();
195
217
 
196
218
  const closeButton = document.querySelector(
197
219
  '.dropin-modal__header-close-button'
@@ -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
- <div
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-modal__body',
119
- [`dropin-modal__body--${size}`, size],
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-modal__header',
126
- ['dropin-modal__header-title', !!title],
120
+ 'dropin-modal__body',
121
+ [`dropin-modal__body--${size}`, size],
122
+ className,
127
123
  ])}
128
124
  >
129
- {title && (
130
- <div className={classes(['dropin-modal__header-title-content'])}>
131
- {title}
132
- </div>
133
- )}
134
-
135
- {showCloseButton && (
136
- <Button
137
- aria-label={translations.modalCloseLabel}
138
- variant="tertiary"
139
- className="dropin-modal__header-close-button"
140
- onClick={handleOnClose}
141
- icon={<CloseSVG />}
142
- />
143
- )}
144
- </div>
145
-
146
- <div
147
- className={classes([
148
- 'dropin-modal__content',
149
- ['dropin-modal__body--centered', centered],
150
- ])}
151
- >
152
- {children}
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
- </div>
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,43 @@
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, render } from 'preact';
11
+ import { FunctionComponent, useEffect, 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
+
20
+ useEffect(() => {
21
+ // Create portal root if it doesn't exist
22
+ if (!portalRoot.current) {
23
+ portalRoot.current = document.createElement('div');
24
+ portalRoot.current.setAttribute('data-portal-root', '');
25
+ portalRoot.current.classList.add('dropin-design');
26
+ document.body.appendChild(portalRoot.current);
27
+ }
28
+
29
+ // Render children into portal root
30
+ render(children, portalRoot.current);
31
+
32
+ // Cleanup
33
+ return () => {
34
+ if (portalRoot.current) {
35
+ render(null, portalRoot.current);
36
+ portalRoot.current.remove();
37
+ portalRoot.current = null;
38
+ }
39
+ };
40
+ }, [children]);
41
+
42
+ return null;
43
+ };
@@ -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>
@@ -40,3 +40,4 @@ export { default as EmptyBox } from './EmptyBox.svg';
40
40
  export { default as Coupon } from './Coupon.svg';
41
41
  export { default as Gift } from './Gift.svg';
42
42
  export { default as GiftCard } from './GiftCard.svg';
43
+ export { default as Edit } from './Edit.svg';
@@ -0,0 +1,207 @@
1
+ import { provider as UI, Image } from '@adobe-commerce/elsie/components';
2
+ import { getConfigValue } from '@adobe-commerce/elsie/lib/aem/configs';
3
+
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
+ };
17
+ width?: number;
18
+ height?: number;
19
+ [key: string]: any;
20
+ }
21
+
22
+ interface AemAssetsImageConfig {
23
+ wrapper?: HTMLElement;
24
+ alias: string;
25
+ params: AemAssetsParams;
26
+ imageProps: {
27
+ src: string;
28
+ width?: number;
29
+ height?: number;
30
+ [key: string]: any;
31
+ };
32
+ src?: string;
33
+ }
34
+
35
+ interface RenderContext {
36
+ replaceWith: (element: HTMLElement) => void;
37
+ }
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
+
56
+ /**
57
+ * Normalizes the given URL to ensure it is a valid URL.
58
+ * @param {string} url - The URL to normalize.
59
+ * @returns {string} The normalized URL.
60
+ */
61
+ function normalizeUrl(url: string): string {
62
+ let imageUrl = url;
63
+
64
+ if (imageUrl.startsWith('//')) {
65
+ // Use current window's protocol.
66
+ const { protocol } = window.location;
67
+ console.log('protocol', protocol);
68
+ imageUrl = protocol + imageUrl;
69
+ }
70
+
71
+ return imageUrl;
72
+ }
73
+
74
+ export function isAemAssetsUrl(url: string | URL): boolean {
75
+ const assetsUrl = typeof url === 'string' ? new URL(normalizeUrl(url)) : url;
76
+
77
+ if (!assetsUrl.pathname.startsWith('/adobe/assets/urn:aaid:aem')) {
78
+ return false;
79
+ }
80
+
81
+ return true;
82
+ }
83
+
84
+ export function generateAemAssetsOptimizedUrl(url: string, alias: string, params: AemAssetsParams = {}): string {
85
+ const defaultParams = getDefaultAemAssetsOptimizationParams();
86
+ const mergedParams: AemAssetsParams = { ...defaultParams, ...params };
87
+
88
+ // Destructure the ones that need special handling
89
+ const {
90
+ format,
91
+ crop,
92
+ size,
93
+ ...optimizedParams
94
+ } = mergedParams;
95
+
96
+ const searchParams = new URLSearchParams(optimizedParams);
97
+
98
+ if (crop) {
99
+ const [xOrigin, yOrigin] = [crop.xOrigin || 0, crop.yOrigin || 0];
100
+ const [width, height] = [crop.width || 100, crop.height || 100];
101
+
102
+ const cropTransform = `${xOrigin}p,${yOrigin}p,${width}p,${height}p`;
103
+ searchParams.set('crop', cropTransform);
104
+ }
105
+
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()}`;
112
+ }
113
+
114
+ export function tryGenerateAemAssetsOptimizedUrl(url: string, alias: string, params: AemAssetsParams = {}): string {
115
+ const assetsEnabled = isAemAssetsEnabled();
116
+
117
+ if (!(assetsEnabled)) {
118
+ // No-op, doesn't do anything.
119
+ return url;
120
+ }
121
+
122
+ const assetsUrl = new URL(normalizeUrl(url));
123
+
124
+ if (!isAemAssetsUrl(assetsUrl)) {
125
+ // Not an AEM Assets URL, so no-op.
126
+ return url;
127
+ }
128
+
129
+ const base = assetsUrl.origin + assetsUrl.pathname;
130
+ return generateAemAssetsOptimizedUrl(base, alias, params);
131
+ }
132
+
133
+ export function makeAemAssetsImageSlot(
134
+ config: AemAssetsImageConfig,
135
+ ) {
136
+ return (ctx: RenderContext) => {
137
+ const {
138
+ wrapper,
139
+ alias,
140
+ params,
141
+ imageProps,
142
+ src,
143
+ } = config;
144
+
145
+ const container = wrapper ?? document.createElement('div');
146
+ const imageSrc = generateAemAssetsOptimizedUrl(src || imageProps.src, alias, params);
147
+
148
+ UI.render(Image as any, {
149
+ ...imageProps,
150
+
151
+ src: imageSrc,
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);
162
+
163
+ ctx.replaceWith(container);
164
+ };
165
+ }
166
+
167
+ export function tryRenderAemAssetsImage(ctx: RenderContext, config: AemAssetsImageConfig): void {
168
+ // Renders an equivalent of the default image.
169
+ function renderDefaultImage(): void {
170
+ const container = config.wrapper ?? document.createElement('div');
171
+ const { imageProps } = config;
172
+
173
+ (UI.render as any)(Image, imageProps)(container);
174
+ ctx.replaceWith(container);
175
+ }
176
+
177
+ const assetsEnabled = isAemAssetsEnabled();
178
+
179
+ if (!(assetsEnabled)) {
180
+ // No-op, render the default image.
181
+ renderDefaultImage();
182
+ return;
183
+ }
184
+
185
+ const { imageProps, src, ...slotConfig } = config;
186
+ const assetsUrl = new URL(normalizeUrl(src ?? imageProps.src));
187
+
188
+ if (!isAemAssetsUrl(assetsUrl)) {
189
+ // Not an AEM Assets URL, so render the default image.
190
+ renderDefaultImage();
191
+ return;
192
+ }
193
+
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);
207
+ }