@adobe-commerce/elsie 1.3.0-beta6 → 1.3.1-alpha007

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.0-beta6",
3
+ "version": "1.3.1-alpha007",
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-beta1",
31
- "@adobe-commerce/recaptcha": "1.0.1-beta2",
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-beta1",
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
- button.dropin-button.dropin-button--primary,
59
- a.dropin-button.dropin-button--primary,
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
- button.dropin-button.dropin-button--primary--disabled,
76
- a.dropin-button.dropin-button--primary--disabled,
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
- button.dropin-button.dropin-button--primary:hover,
86
- a.dropin-button.dropin-button--primary:hover,
85
+ .dropin-button--primary:hover,
86
+ a.dropin-button--primary:hover,
87
87
  .dropin-iconButton--primary:hover,
88
- button.dropin-button.dropin-button--primary:focus:hover,
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
- button.dropin-button.dropin-button--primary:focus,
94
+ .dropin-button--primary:focus,
95
95
  .dropin-iconButton--primary:focus {
96
96
  background-color: var(--color-brand-500);
97
97
  }
98
98
 
99
- button.dropin-button.dropin-button--primary:hover:active,
99
+ .dropin-button--primary:hover:active,
100
100
  .dropin-iconButton--primary:hover:active {
101
101
  background-color: var(--color-button-active);
102
102
  }
@@ -241,10 +241,15 @@
241
241
  }
242
242
 
243
243
  .dropin-cart-item__price,
244
+ .dropin-cart-item__footer div,
244
245
  .dropin-cart-item__attributes div {
245
246
  margin-top: var(--group-spacing);
246
247
  }
247
248
 
249
+ .dropin-cart-item__footer p {
250
+ margin-top: 0;
251
+ }
252
+
248
253
  .dropin-cart-item__quantity:not(.dropin-cart-item__quantity--edit) {
249
254
  display: none;
250
255
  }
@@ -62,7 +62,6 @@ const lazyIcons = {
62
62
  Coupon: lazy(() => import('@adobe-commerce/elsie/icons/Coupon.svg')),
63
63
  Gift: lazy(() => import('@adobe-commerce/elsie/icons/Gift.svg')),
64
64
  GiftCard: lazy(() => import('@adobe-commerce/elsie/icons/GiftCard.svg')),
65
- Edit: lazy(() => import('@adobe-commerce/elsie/icons/Edit.svg')),
66
65
  };
67
66
 
68
67
  export interface IconProps extends Omit<SVGProps<SVGSVGElement>, 'size'> {
@@ -10,10 +10,10 @@
10
10
  /* https://cssguidelin.es/#bem-like-naming */
11
11
 
12
12
  .dropin-modal {
13
+ height: 100vh;
14
+ width: 100vw;
13
15
  position: fixed;
14
16
  top: 0;
15
- right: 0;
16
- bottom: 0;
17
17
  left: 0;
18
18
  z-index: 99999;
19
19
  overflow: auto;
@@ -11,8 +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, waitFor } from '@storybook/test';
15
- import { useText } from '@adobe-commerce/elsie/i18n';
14
+ import { expect, userEvent, within } from '@storybook/test';
16
15
 
17
16
  const meta: Meta<ModalProps> = {
18
17
  title: 'Components/Modal',
@@ -108,21 +107,13 @@ export const SmallModal: Story = {
108
107
  const canvas = within(canvasElement);
109
108
  await userEvent.click(canvas.getByRole('button'));
110
109
 
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
-
119
110
  const modal = document.querySelector(
120
111
  '.dropin-modal__body'
121
112
  ) as HTMLDivElement;
122
113
 
123
114
  await expect(modal).toBeVisible();
124
115
 
125
- expect(portalRoot.querySelector('h4')?.innerText).toBe('Small modal');
116
+ await expect(await canvas.findByText('Small modal')).toBeVisible();
126
117
 
127
118
  const closeButton = document.querySelector(
128
119
  '.dropin-modal__header-close-button'
@@ -157,21 +148,13 @@ export const MediumModal: Story = {
157
148
  const canvas = within(canvasElement);
158
149
  await userEvent.click(canvas.getByRole('button'));
159
150
 
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
-
168
151
  const modal = document.querySelector(
169
152
  '.dropin-modal__body'
170
153
  ) as HTMLDivElement;
171
154
 
172
155
  await expect(modal).toBeVisible();
173
156
 
174
- expect(portalRoot.querySelector('h3')?.innerText).toBe('Medium modal');
157
+ await expect(await canvas.findByText('Medium modal')).toBeVisible();
175
158
 
176
159
  const closeButton = document.querySelector(
177
160
  '.dropin-modal__header-close-button'
@@ -204,17 +187,11 @@ export const FullModal: Story = {
204
187
  const canvas = within(canvasElement);
205
188
  await userEvent.click(canvas.getByRole('button'));
206
189
 
207
- const portalRoot = await waitFor(() => {
208
- const root = document.querySelector('[data-portal-root]') as HTMLDivElement;
209
- expect(root).toBeTruthy();
210
- return root;
211
- });
212
-
213
190
  const modal = document.querySelector(
214
191
  '.dropin-modal__body'
215
192
  ) as HTMLDivElement;
216
193
 
217
- await expect(portalRoot).toBeVisible();
194
+ await expect(modal).toBeVisible();
218
195
 
219
196
  const closeButton = document.querySelector(
220
197
  '.dropin-modal__header-close-button'
@@ -270,65 +247,4 @@ export const OverflowingTitle: Story = {
270
247
  },
271
248
  };
272
249
 
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
250
  export default meta;
@@ -18,7 +18,6 @@ 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';
22
21
 
23
22
  import '@adobe-commerce/elsie/components/Modal/Modal.css';
24
23
 
@@ -107,54 +106,52 @@ export const Modal: FunctionComponent<ModalProps> = ({
107
106
  }, []);
108
107
 
109
108
  return (
110
- <Portal>
109
+ <div
110
+ className={classes([
111
+ 'dropin-modal',
112
+ ['dropin-modal--dim', backgroundDim],
113
+ ])}
114
+ >
111
115
  <div
116
+ {...props}
112
117
  className={classes([
113
- 'dropin-modal',
114
- ['dropin-modal--dim', backgroundDim],
118
+ 'dropin-modal__body',
119
+ [`dropin-modal__body--${size}`, size],
120
+ className,
115
121
  ])}
116
122
  >
117
123
  <div
118
- {...props}
119
124
  className={classes([
120
- 'dropin-modal__body',
121
- [`dropin-modal__body--${size}`, size],
122
- className,
125
+ 'dropin-modal__header',
126
+ ['dropin-modal__header-title', !!title],
123
127
  ])}
124
128
  >
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>
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}
156
153
  </div>
157
154
  </div>
158
- </Portal>
155
+ </div>
159
156
  );
160
157
  };
@@ -40,4 +40,3 @@ 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';
@@ -1,145 +0,0 @@
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
- };
@@ -1,49 +0,0 @@
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
- };
@@ -1 +0,0 @@
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>
@@ -1,323 +0,0 @@
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
- }