@adobe-commerce/elsie 1.3.1-alpha012 → 1.3.1-alpha013

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-alpha012",
3
+ "version": "1.3.1-alpha013",
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-beta1",
31
+ "@adobe-commerce/recaptcha": "1.0.1-beta2",
32
32
  "@adobe-commerce/storefront-design": "~1.0.0",
33
- "@dropins/build-tools": "~1.0.1",
33
+ "@dropins/build-tools": "1.0.1-beta1",
34
34
  "preact": "~10.22.1",
35
35
  "vite-plugin-banner": "^0.8.0"
36
36
  },
@@ -12,6 +12,7 @@ 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';
15
16
 
16
17
  const meta: Meta<ModalProps> = {
17
18
  title: 'Components/Modal',
@@ -269,4 +270,65 @@ export const OverflowingTitle: Story = {
269
270
  },
270
271
  };
271
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
+
272
334
  export default meta;
@@ -7,8 +7,8 @@
7
7
  * accompanying it.
8
8
  *******************************************************************/
9
9
 
10
- import { ComponentChildren, render } from 'preact';
11
- import { FunctionComponent, useEffect, useRef } from 'preact/compat';
10
+ import { ComponentChildren } from 'preact';
11
+ import { FunctionComponent, useLayoutEffect, useRef } from 'preact/compat';
12
12
 
13
13
  interface PortalProps {
14
14
  children: ComponentChildren;
@@ -16,28 +16,34 @@ 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);
19
20
 
20
- useEffect(() => {
21
+ useLayoutEffect(() => {
21
22
  // Create portal root if it doesn't exist
22
23
  if (!portalRoot.current) {
23
24
  portalRoot.current = document.createElement('div');
24
25
  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
- // Render children into portal root
30
- render(children, portalRoot.current);
29
+ // Move content to portal root
30
+ if (contentRef.current && portalRoot.current) {
31
+ portalRoot.current.appendChild(contentRef.current);
32
+ }
31
33
 
32
34
  // Cleanup
33
35
  return () => {
34
36
  if (portalRoot.current) {
35
- render(null, portalRoot.current);
36
37
  portalRoot.current.remove();
37
38
  portalRoot.current = null;
38
39
  }
39
40
  };
40
- }, [children]);
41
+ }, []);
41
42
 
42
- return null;
43
+ // Return a div that contains the children
44
+ return (
45
+ <div ref={contentRef} className="dropin-design">
46
+ {children}
47
+ </div>
48
+ );
43
49
  };
@@ -1,56 +1,102 @@
1
- import { provider as UI, Image } from '@adobe-commerce/elsie/components';
1
+ import {
2
+ provider as UI,
3
+ Image,
4
+ type ImageProps,
5
+ } from '@adobe-commerce/elsie/components';
6
+
2
7
  import { getConfigValue } from '@adobe-commerce/elsie/lib/aem/configs';
8
+ import type { ResolveImageUrlOptions } from '../resolve-image';
3
9
 
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
- };
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) */
17
43
  width?: number;
44
+
45
+ /** The height of the crop (between 0 and 100) */
18
46
  height?: number;
19
- [key: string]: any;
20
47
  }
21
48
 
22
- interface AemAssetsImageConfig {
23
- wrapper?: HTMLElement;
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 */
24
84
  alias: string;
25
- params: AemAssetsParams;
26
- imageProps: {
85
+
86
+ /** The props to be applied to the underlying {@link Image} component */
87
+ imageProps: Partial<Omit<ImageProps, 'params' | 'width' | 'height'>> & {
27
88
  src: string;
28
- width?: number;
29
- height?: number;
30
- [key: string]: any;
31
89
  };
32
- src?: string;
33
- }
34
-
35
- interface RenderContext {
36
- replaceWith: (element: HTMLElement) => void;
37
- }
38
90
 
39
- export function isAemAssetsEnabled(): boolean {
40
- const config = getConfigValue('commerce-assets-enabled');
91
+ /** The parameters to be applied to the asset (known width required when using a slot) */
92
+ params: AemAssetsImageSlotConfigParams;
41
93
 
42
- return config && (
43
- (typeof config === 'string' && config.toLowerCase() === 'true')
44
- || (typeof config === 'boolean' && config === true)
45
- );
94
+ /** The element that will contain the image in the slot */
95
+ wrapper?: HTMLElement;
46
96
  }
47
97
 
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
- };
98
+ interface RenderContext {
99
+ replaceWith: (element: HTMLElement) => void;
54
100
  }
55
101
 
56
102
  /**
@@ -64,13 +110,59 @@ function normalizeUrl(url: string): string {
64
110
  if (imageUrl.startsWith('//')) {
65
111
  // Use current window's protocol.
66
112
  const { protocol } = window.location;
67
- console.log('protocol', protocol);
68
113
  imageUrl = protocol + imageUrl;
69
114
  }
70
115
 
71
116
  return imageUrl;
72
117
  }
73
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. */
74
166
  export function isAemAssetsUrl(url: string | URL): boolean {
75
167
  const assetsUrl = typeof url === 'string' ? new URL(normalizeUrl(url)) : url;
76
168
 
@@ -81,19 +173,30 @@ export function isAemAssetsUrl(url: string | URL): boolean {
81
173
  return true;
82
174
  }
83
175
 
84
- export function generateAemAssetsOptimizedUrl(url: string, alias: string, params: AemAssetsParams = {}): string {
176
+ /** Generates an optimized URL for AEM Assets. */
177
+ export function generateAemAssetsOptimizedUrl(
178
+ assetUrl: string,
179
+ alias: string,
180
+ params: Partial<AemAssetsParams> = {}
181
+ ): string {
85
182
  const defaultParams = getDefaultAemAssetsOptimizationParams();
86
183
  const mergedParams: AemAssetsParams = { ...defaultParams, ...params };
87
184
 
88
- // Destructure the ones that need special handling
89
- const {
90
- format,
91
- crop,
92
- size,
93
- ...optimizedParams
94
- } = mergedParams;
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
+ );
95
198
 
96
- const searchParams = new URLSearchParams(optimizedParams);
199
+ const searchParams = new URLSearchParams(stringifiedParams);
97
200
 
98
201
  if (crop) {
99
202
  const [xOrigin, yOrigin] = [crop.xOrigin || 0, crop.yOrigin || 0];
@@ -103,87 +206,112 @@ export function generateAemAssetsOptimizedUrl(url: string, alias: string, params
103
206
  searchParams.set('crop', cropTransform);
104
207
  }
105
208
 
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()}`;
209
+ return `${assetUrl}/as/${alias}.${format}?${searchParams.toString()}`;
112
210
  }
113
211
 
114
- export function tryGenerateAemAssetsOptimizedUrl(url: string, alias: string, params: AemAssetsParams = {}): string {
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 {
115
221
  const assetsEnabled = isAemAssetsEnabled();
116
222
 
117
- if (!(assetsEnabled)) {
223
+ if (!assetsEnabled) {
118
224
  // No-op, doesn't do anything.
119
- return url;
225
+ return assetUrl;
120
226
  }
121
227
 
122
- const assetsUrl = new URL(normalizeUrl(url));
228
+ const assetsUrl = new URL(normalizeUrl(assetUrl));
123
229
 
124
230
  if (!isAemAssetsUrl(assetsUrl)) {
125
231
  // Not an AEM Assets URL, so no-op.
126
- return url;
232
+ return assetUrl;
127
233
  }
128
234
 
129
235
  const base = assetsUrl.origin + assetsUrl.pathname;
130
236
  return generateAemAssetsOptimizedUrl(base, alias, params);
131
237
  }
132
238
 
133
- export function makeAemAssetsImageSlot(
134
- config: AemAssetsImageConfig,
135
- ) {
239
+ /** Creates a slot that renders an AEM Assets image. */
240
+ export function makeAemAssetsImageSlot(config: AemAssetsImageSlotConfig) {
136
241
  return (ctx: RenderContext) => {
137
- const {
138
- wrapper,
139
- alias,
140
- params,
141
- imageProps,
142
- src,
143
- } = config;
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
+ }
144
249
 
145
250
  const container = wrapper ?? document.createElement('div');
146
- const imageSrc = generateAemAssetsOptimizedUrl(src || imageProps.src, alias, params);
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,
147
260
 
148
- UI.render(Image as any, {
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 = {
149
269
  ...imageProps,
270
+ width: params.width,
271
+ height: params.height,
150
272
 
151
273
  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);
274
+ params: imageComponentParams,
275
+ };
162
276
 
277
+ UI.render(Image, imageComponentProps)(container);
163
278
  ctx.replaceWith(container);
164
279
  };
165
280
  }
166
281
 
167
- export function tryRenderAemAssetsImage(ctx: RenderContext, config: AemAssetsImageConfig): void {
282
+ export function tryRenderAemAssetsImage(
283
+ ctx: RenderContext,
284
+ config: AemAssetsImageSlotConfig
285
+ ): void {
168
286
  // Renders an equivalent of the default image.
169
287
  function renderDefaultImage(): void {
170
288
  const container = config.wrapper ?? document.createElement('div');
171
- const { imageProps } = config;
289
+ const { imageProps, params } = config;
290
+ const imageComponentProps: ImageProps = {
291
+ ...imageProps,
292
+ width: params.width,
293
+ height: params.height,
294
+ };
172
295
 
173
- (UI.render as any)(Image, imageProps)(container);
296
+ UI.render(Image, imageComponentProps)(container);
174
297
  ctx.replaceWith(container);
175
298
  }
176
299
 
177
300
  const assetsEnabled = isAemAssetsEnabled();
178
301
 
179
- if (!(assetsEnabled)) {
302
+ if (!assetsEnabled) {
180
303
  // No-op, render the default image.
181
304
  renderDefaultImage();
182
305
  return;
183
306
  }
184
307
 
185
- const { imageProps, src, ...slotConfig } = config;
186
- const assetsUrl = new URL(normalizeUrl(src ?? imageProps.src));
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));
187
315
 
188
316
  if (!isAemAssetsUrl(assetsUrl)) {
189
317
  // Not an AEM Assets URL, so render the default image.
@@ -191,17 +319,5 @@ export function tryRenderAemAssetsImage(ctx: RenderContext, config: AemAssetsIma
191
319
  return;
192
320
  }
193
321
 
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);
322
+ makeAemAssetsImageSlot(config)(ctx);
207
323
  }