@adobe-commerce/elsie 1.2.1 → 1.3.0-alpha01

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/config/vite.mjs CHANGED
@@ -25,9 +25,11 @@ import banner from 'vite-plugin-banner';
25
25
  const env = loadEnv('', process.cwd());
26
26
 
27
27
  // Load Elsie Config
28
- const elsieConfig = await import(
29
- path.resolve(process.cwd(), './.elsie.js')
30
- ).then((m) => m.default);
28
+ const elsieConfig = await import(path.resolve(process.cwd(), './.elsie.js')).then((m) => m.default);
29
+
30
+ // Read package.json using createRequire (compatible with Node 20 and 22)
31
+ const require = createRequire(import.meta.url);
32
+ const packageJSON = require(path.resolve(process.cwd(), './package.json'));
31
33
 
32
34
  // Paths
33
35
  const paths = {
@@ -116,7 +118,6 @@ export default {
116
118
  entryFileNames: '[name].js',
117
119
  assetFileNames: '[name].[ext]',
118
120
  chunkFileNames: 'chunks/[name].js',
119
-
120
121
  manualChunks: (id) => {
121
122
  if (id.includes(paths.fragments)) {
122
123
  // Fragments file does not accept chunking
@@ -176,7 +177,7 @@ export default {
176
177
  modulePreload: false,
177
178
  commonjsOptions: { transformMixedEsModules: true },
178
179
  minify: !!isProd,
179
- sourcemap: !isProd,
180
+ sourcemap: true,
180
181
  },
181
182
 
182
183
  optimizeDeps: {},
@@ -287,6 +288,39 @@ export default {
287
288
  },
288
289
  }),
289
290
 
291
+ {
292
+ name: 'rewrite-sourcemap-sources',
293
+ generateBundle(options, bundle) {
294
+ for (const fileName in bundle) {
295
+ const chunk = bundle[fileName];
296
+
297
+ // Process both .map files and JS/TS files with sourcemaps
298
+ if ((chunk.type === 'asset' && fileName.endsWith('.map')) ||
299
+ (chunk.type === 'chunk' && chunk.map)) {
300
+ try {
301
+ // Get the sourcemap object - either from the asset source or the chunk's map
302
+ const map = chunk.type === 'asset' ? JSON.parse(chunk.source) : chunk.map;
303
+
304
+ if (map.sources) {
305
+ map.sources = map.sources.map((input) => {
306
+ return input.replace(/(?:\.\.?\/)+src\//, `/${packageJSON.name}/src/`);
307
+ });
308
+
309
+ // Update the sourcemap in the appropriate place
310
+ if (chunk.type === 'asset') {
311
+ chunk.source = JSON.stringify(map);
312
+ } else {
313
+ chunk.map = map;
314
+ }
315
+ }
316
+ } catch (e) {
317
+ console.error('Error transforming sourcemap:', e);
318
+ }
319
+ }
320
+ }
321
+ }
322
+ },
323
+
290
324
  process.env.ANALYZE
291
325
  ? visualizer({
292
326
  title: `${elsieConfig.name} Dropin Bundle Analysis`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe-commerce/elsie",
3
- "version": "1.2.1",
3
+ "version": "1.3.0-alpha01",
4
4
  "license": "SEE LICENSE IN LICENSE.md",
5
5
  "description": "Domain Package SDK",
6
6
  "engines": {
@@ -33,6 +33,12 @@
33
33
  overflow: hidden;
34
34
  }
35
35
 
36
+ .dropin-image-swatch__span span {
37
+ display: inline-block;
38
+ width: 100%;
39
+ height: 100%;
40
+ }
41
+
36
42
  .dropin-image-swatch__span img,
37
43
  .dropin-image-swatch__content {
38
44
  width: inherit;
@@ -0,0 +1,87 @@
1
+ /********************************************************************
2
+ * Copyright 2025 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
+ /* https://cssguidelin.es/#bem-like-naming */
11
+
12
+ .dropin-product-item-card {
13
+ display: grid;
14
+ position: relative;
15
+ grid-auto-flow: row;
16
+ background: var(--color-neutral-50);
17
+ border: 1px solid var(--color-neutral-400);
18
+ color: var(--color-neutral-800);
19
+ font: var(--type-body-1-default-font);
20
+ letter-spacing: var(--type-body-1-default-letter-spacing);
21
+ margin: var(--spacing-small);
22
+ width: 300px;
23
+ }
24
+
25
+ .dropin-product-item-card__image-container {
26
+ overflow: hidden;
27
+ width: 100%;
28
+ height: auto;
29
+ }
30
+
31
+ .dropin-product-item-card__image img {
32
+ width: 100%;
33
+ max-height: 375px;
34
+ }
35
+
36
+ .dropin-product-item-card__content {
37
+ display: grid;
38
+ grid-template-columns: 1fr 1fr;
39
+ padding: var(--spacing-small);
40
+ gap: var(--spacing-xxsmall);
41
+ align-items: center;
42
+ color: var(--color-neutral-800);
43
+ }
44
+
45
+ .dropin-product-item-card__title,
46
+ .dropin-product-item-card__sku,
47
+ .dropin-product-item-card__price,
48
+ .dropin-product-item-card__swatches,
49
+ .dropin-product-item-card__action {
50
+ grid-column: 1/3;
51
+ }
52
+
53
+ .dropin-product-item-card__title {
54
+ font: var(--type-body-1-strong-font);
55
+ letter-spacing: var(--type-body-1-strong-letter-spacing);
56
+ }
57
+
58
+ .dropin-product-item-card__sku {
59
+ font: var(--type-body-1-default-font);
60
+ letter-spacing: var(--type-body-1-default-letter-spacing);
61
+ }
62
+
63
+ .dropin-product-item-card__price {
64
+ font: var(--type-body-1-default-font);
65
+ letter-spacing: var(--type-body-1-default-letter-spacing);
66
+ }
67
+
68
+ .dropin-product-item-card__swatches {
69
+ margin-top: var(--spacing-xsmall);
70
+ }
71
+
72
+ .dropin-product-item-card__action {
73
+ margin-top: var(--spacing-xsmall);
74
+ width: 100%;
75
+ }
76
+
77
+ /* Medium (portrait tablets and large phones, 768px and up) */
78
+ /* @media only screen and (min-width: 768px) { } */
79
+
80
+ /* Large (landscape tablets, 1024px and up) */
81
+ /* @media only screen and (min-width: 1024px) { } */
82
+
83
+ /* XLarge (laptops/desktops, 1366px and up) */
84
+ /* @media only screen and (min-width: 1366px) { } */
85
+
86
+ /* XXlarge (large laptops and desktops, 1920px and up) */
87
+ /* @media only screen and (min-width: 1920px) { } */
@@ -0,0 +1,371 @@
1
+ /********************************************************************
2
+ * ADOBE CONFIDENTIAL
3
+ * __________________
4
+ *
5
+ * Copyright 2025 Adobe
6
+ * All Rights Reserved.
7
+ *
8
+ * NOTICE: All information contained herein is, and remains
9
+ * the property of Adobe and its suppliers, if any. The intellectual
10
+ * and technical concepts contained herein are proprietary to Adobe
11
+ * and its suppliers and are protected by all applicable intellectual
12
+ * property laws, including trade secret and copyright laws.
13
+ * Dissemination of this information or reproduction of this material
14
+ * is strictly forbidden unless prior written permission is obtained
15
+ * from Adobe.
16
+ *******************************************************************/
17
+
18
+ // https://storybook.js.org/docs/7.0/preact/writing-stories/introduction
19
+ import type { Meta, StoryObj } from '@storybook/preact';
20
+ import {
21
+ Button,
22
+ Icon,
23
+ Image,
24
+ Price,
25
+ ProductItemCard as component,
26
+ ProductItemCardProps,
27
+ ColorSwatch,
28
+ } from '@adobe-commerce/elsie/components';
29
+ import { Cart } from '@adobe-commerce/elsie/icons';
30
+ /**
31
+ * Use ProductItemCard to display product recommendations with image, title, price, SKU, and action button.
32
+ */
33
+ const meta: Meta<ProductItemCardProps> = {
34
+ title: 'Components/ProductItemCard',
35
+ component,
36
+
37
+ argTypes: {
38
+ image: {
39
+ control: {
40
+ type: 'select',
41
+ labels: {
42
+ DefaultImage: 'Default Image',
43
+ Empty: 'No Image',
44
+ },
45
+ },
46
+ description: 'Product image node.',
47
+ options: ['DefaultImage', 'Empty'],
48
+ mapping: {
49
+ DefaultImage: (
50
+ <Image
51
+ src="https://picsum.photos/300/375"
52
+ width="300"
53
+ height="375"
54
+ alt="Product Image"
55
+ loading="lazy"
56
+ />
57
+ ),
58
+ Empty: null,
59
+ },
60
+ table: { defaultValue: { summary: 'null' } },
61
+ },
62
+ titleNode: {
63
+ control: {
64
+ type: 'select',
65
+ labels: {
66
+ DefaultTitle: 'Default Title',
67
+ LongTitle: 'Long Title',
68
+ Empty: 'No Title',
69
+ },
70
+ },
71
+ description: 'Product title node.',
72
+ options: ['DefaultTitle', 'LongTitle', 'Empty'],
73
+ mapping: {
74
+ DefaultTitle: <div>Hollister Backyard Sweatshirt</div>,
75
+ LongTitle: (
76
+ <div>
77
+ Hollister Backyard Sweatshirt with Extra Long Product Name That
78
+ Might Wrap
79
+ </div>
80
+ ),
81
+ Empty: null,
82
+ },
83
+ },
84
+ price: {
85
+ control: {
86
+ type: 'select',
87
+ labels: {
88
+ DefaultPrice: 'Default Price',
89
+ SalePrice: 'Sale Price',
90
+ Empty: 'No Price',
91
+ },
92
+ },
93
+ description: 'Product price node.',
94
+ options: ['DefaultPrice', 'SalePrice', 'Empty'],
95
+ mapping: {
96
+ DefaultPrice: (
97
+ <>
98
+ <Price amount={49.99} />
99
+ </>
100
+ ),
101
+ SalePrice: (
102
+ <>
103
+ <Price amount={69.99} variant="strikethrough" />
104
+ <Price amount={49.99} sale />
105
+ </>
106
+ ),
107
+ Empty: null,
108
+ },
109
+ },
110
+ sku: {
111
+ control: {
112
+ type: 'select',
113
+ labels: {
114
+ DefaultSku: 'Default SKU',
115
+ Empty: 'No SKU',
116
+ },
117
+ },
118
+ description: 'Product SKU node.',
119
+ options: ['DefaultSku', 'Empty'],
120
+ mapping: {
121
+ DefaultSku: <div>SKU: 123456789</div>,
122
+ Empty: null,
123
+ },
124
+ },
125
+ swatches: {
126
+ control: {
127
+ type: 'select',
128
+ labels: {
129
+ DefaultSwatches: 'Default Swatches',
130
+ SelectedSwatches: 'Selected Swatches',
131
+ OutOfStockSwatches: 'Out of Stock Swatches',
132
+ Empty: 'No Swatches',
133
+ },
134
+ },
135
+ description: 'Product swatches node.',
136
+ options: [
137
+ 'DefaultSwatches',
138
+ 'SelectedSwatches',
139
+ 'OutOfStockSwatches',
140
+ 'Empty',
141
+ ],
142
+ mapping: {
143
+ DefaultSwatches: (
144
+ <div style={{ display: 'flex', gap: '8px' }}>
145
+ <ColorSwatch
146
+ color="red"
147
+ label="Red"
148
+ groupAriaLabel="Color options"
149
+ value="red"
150
+ />
151
+ <ColorSwatch
152
+ color="blue"
153
+ label="Blue"
154
+ groupAriaLabel="Color options"
155
+ value="blue"
156
+ />
157
+ <ColorSwatch
158
+ color="green"
159
+ label="Green"
160
+ groupAriaLabel="Color options"
161
+ value="green"
162
+ />
163
+ </div>
164
+ ),
165
+ SelectedSwatches: (
166
+ <div style={{ display: 'flex', gap: '8px' }}>
167
+ <ColorSwatch
168
+ color="red"
169
+ label="Red"
170
+ groupAriaLabel="Color options"
171
+ value="red"
172
+ selected
173
+ />
174
+ <ColorSwatch
175
+ color="blue"
176
+ label="Blue"
177
+ groupAriaLabel="Color options"
178
+ value="blue"
179
+ />
180
+ <ColorSwatch
181
+ color="green"
182
+ label="Green"
183
+ groupAriaLabel="Color options"
184
+ value="green"
185
+ />
186
+ </div>
187
+ ),
188
+ OutOfStockSwatches: (
189
+ <div style={{ display: 'flex', gap: '8px' }}>
190
+ <ColorSwatch
191
+ color="red"
192
+ label="Red"
193
+ groupAriaLabel="Color options"
194
+ value="red"
195
+ outOfStock
196
+ />
197
+ <ColorSwatch
198
+ color="blue"
199
+ label="Blue"
200
+ groupAriaLabel="Color options"
201
+ value="blue"
202
+ />
203
+ <ColorSwatch
204
+ color="green"
205
+ label="Green"
206
+ groupAriaLabel="Color options"
207
+ value="green"
208
+ />
209
+ </div>
210
+ ),
211
+ Empty: null,
212
+ },
213
+ table: { defaultValue: { summary: 'null' } },
214
+ },
215
+ actionButton: {
216
+ control: {
217
+ type: 'select',
218
+ labels: {
219
+ DefaultButton: 'Default Button',
220
+ CustomButton: 'Custom Button',
221
+ Empty: 'No Button',
222
+ },
223
+ },
224
+ description: 'Action button node.',
225
+ options: ['DefaultButton', 'CustomButton', 'Empty'],
226
+ mapping: {
227
+ DefaultButton: <Button>Select Options</Button>,
228
+ CustomButton: (
229
+ <Button icon={<Icon source={Cart} size="24" />} variant="primary">
230
+ Add to Cart
231
+ </Button>
232
+ ),
233
+ Empty: null,
234
+ },
235
+ table: { defaultValue: { summary: 'null' } },
236
+ },
237
+ },
238
+ };
239
+
240
+ export default meta;
241
+
242
+ type Story = StoryObj<ProductItemCardProps>;
243
+
244
+ /**
245
+ * Default ProductItemCard with all elements
246
+ */
247
+ export const Default: Story = {
248
+ args: {
249
+ initialized: true,
250
+ image: 'DefaultImage' as any,
251
+ titleNode: 'DefaultTitle' as any,
252
+ price: 'DefaultPrice' as any,
253
+ sku: 'DefaultSku' as any,
254
+ swatches: 'DefaultSwatches' as any,
255
+ actionButton: 'DefaultButton' as any,
256
+ },
257
+ };
258
+
259
+ /**
260
+ * ProductItemCard with long title
261
+ */
262
+ export const LongTitle: Story = {
263
+ args: {
264
+ initialized: true,
265
+ image: 'DefaultImage' as any,
266
+ titleNode: 'LongTitle' as any,
267
+ price: 'DefaultPrice' as any,
268
+ sku: 'DefaultSku' as any,
269
+ swatches: 'DefaultSwatches' as any,
270
+ actionButton: 'DefaultButton' as any,
271
+ },
272
+ };
273
+
274
+ /**
275
+ * ProductItemCard with sale price
276
+ */
277
+ export const SalePrice: Story = {
278
+ args: {
279
+ initialized: true,
280
+ image: 'DefaultImage' as any,
281
+ titleNode: 'DefaultTitle' as any,
282
+ price: 'SalePrice' as any,
283
+ sku: 'DefaultSku' as any,
284
+ swatches: 'DefaultSwatches' as any,
285
+ actionButton: 'DefaultButton' as any,
286
+ },
287
+ };
288
+
289
+ /**
290
+ * ProductItemCard without action button
291
+ */
292
+ export const NoButton: Story = {
293
+ args: {
294
+ initialized: true,
295
+ image: 'DefaultImage' as any,
296
+ titleNode: 'DefaultTitle' as any,
297
+ price: 'DefaultPrice' as any,
298
+ sku: 'DefaultSku' as any,
299
+ swatches: 'DefaultSwatches' as any,
300
+ actionButton: 'Empty' as any,
301
+ },
302
+ };
303
+
304
+ /**
305
+ * ProductItemCard without image
306
+ */
307
+ export const NoImage: Story = {
308
+ args: {
309
+ initialized: true,
310
+ image: 'Empty' as any,
311
+ titleNode: 'DefaultTitle' as any,
312
+ price: 'DefaultPrice' as any,
313
+ sku: 'DefaultSku' as any,
314
+ swatches: 'DefaultSwatches' as any,
315
+ actionButton: 'DefaultButton' as any,
316
+ },
317
+ };
318
+
319
+ /**
320
+ * ProductItemCard with minimal content
321
+ */
322
+ export const Minimal: Story = {
323
+ args: {
324
+ initialized: true,
325
+ image: 'DefaultImage' as any,
326
+ titleNode: 'DefaultTitle' as any,
327
+ price: 'Empty' as any,
328
+ sku: 'Empty' as any,
329
+ swatches: 'Empty' as any,
330
+ actionButton: 'Empty' as any,
331
+ },
332
+ };
333
+
334
+ /**
335
+ * ProductItemCard with selected swatch
336
+ */
337
+ export const SelectedSwatch: Story = {
338
+ args: {
339
+ initialized: true,
340
+ image: 'DefaultImage' as any,
341
+ titleNode: 'DefaultTitle' as any,
342
+ price: 'DefaultPrice' as any,
343
+ sku: 'DefaultSku' as any,
344
+ swatches: 'SelectedSwatches' as any,
345
+ actionButton: 'DefaultButton' as any,
346
+ },
347
+ };
348
+
349
+ /**
350
+ * ProductItemCard with out of stock swatch
351
+ */
352
+ export const OutOfStockSwatch: Story = {
353
+ args: {
354
+ initialized: true,
355
+ image: 'DefaultImage' as any,
356
+ titleNode: 'DefaultTitle' as any,
357
+ price: 'DefaultPrice' as any,
358
+ sku: 'DefaultSku' as any,
359
+ swatches: 'OutOfStockSwatches' as any,
360
+ actionButton: 'DefaultButton' as any,
361
+ },
362
+ };
363
+
364
+ /**
365
+ * ProductItemCard in skeleton loading state
366
+ */
367
+ export const Skeleton: Story = {
368
+ args: {
369
+ initialized: false,
370
+ },
371
+ };
@@ -0,0 +1,94 @@
1
+ /********************************************************************
2
+ * Copyright 2025 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 { FunctionComponent, VNode } from 'preact';
11
+ import { HTMLAttributes } from 'preact/compat';
12
+ import { VComponent, classes } from '@adobe-commerce/elsie/lib';
13
+ import { ProductItemCardSkeleton } from '@adobe-commerce/elsie/components/ProductItemCard/ProductItemCardSkeleton';
14
+ import '@adobe-commerce/elsie/components/ProductItemCard/ProductItemCard.css';
15
+
16
+ export interface ProductItemCardProps
17
+ extends Omit<HTMLAttributes<HTMLDivElement>, 'loading'> {
18
+ image?: VNode;
19
+ titleNode?: VNode;
20
+ price?: VNode;
21
+ sku?: VNode;
22
+ actionButton?: VNode;
23
+ swatches?: VNode;
24
+ initialized?: boolean;
25
+ }
26
+
27
+ export const ProductItemCard: FunctionComponent<ProductItemCardProps> = ({
28
+ className,
29
+ image,
30
+ titleNode,
31
+ price,
32
+ sku,
33
+ actionButton,
34
+ swatches,
35
+ initialized = false,
36
+ ...props
37
+ }) => {
38
+ if (!initialized) {
39
+ return <ProductItemCardSkeleton />;
40
+ }
41
+ return (
42
+ <div
43
+ {...props}
44
+ className={classes(['dropin-product-item-card', className])}
45
+ >
46
+ <div className="dropin-product-item-card__image-container">
47
+ {image && (
48
+ <VComponent
49
+ node={image}
50
+ className={classes(['dropin-product-item-card__image'])}
51
+ />
52
+ )}
53
+ </div>
54
+ <div className="dropin-product-item-card__content">
55
+ {titleNode && (
56
+ <VComponent
57
+ node={titleNode}
58
+ className={classes(['dropin-product-item-card__title'])}
59
+ />
60
+ )}
61
+ {sku && (
62
+ <VComponent
63
+ node={sku}
64
+ className={classes(['dropin-product-item-card__sku'])}
65
+ />
66
+ )}
67
+ {price && (
68
+ <div className="dropin-product-item-card__price">
69
+ <VComponent
70
+ node={price}
71
+ className={classes(['dropin-product-item-card__price'])}
72
+ />
73
+ </div>
74
+ )}
75
+ {swatches && (
76
+ <div className="dropin-product-item-card__swatches">
77
+ <VComponent
78
+ node={swatches}
79
+ className={classes(['dropin-product-item-card__swatches'])}
80
+ />
81
+ </div>
82
+ )}
83
+ {actionButton && (
84
+ <div className="dropin-product-item-card__action">
85
+ <VComponent
86
+ node={actionButton}
87
+ className={classes(['dropin-product-item-card__action'])}
88
+ />
89
+ </div>
90
+ )}
91
+ </div>
92
+ </div>
93
+ );
94
+ };
@@ -0,0 +1,40 @@
1
+ /********************************************************************
2
+ * Copyright 2025 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
+ /* https://cssguidelin.es/#bem-like-naming */
11
+
12
+ .dropin-product-item-card__skeleton {
13
+ gap: var(--spacing-small);
14
+ }
15
+
16
+ .dropin-product-item-card__skeleton__image {
17
+ height: 375px;
18
+ }
19
+
20
+ .dropin-product-item-card__skeleton__content {
21
+ grid-column: 1/-1;
22
+ }
23
+
24
+ /* .dropin-product-item-card__skeleton__image {
25
+ width: 100%;
26
+ height: auto;
27
+ }
28
+ */
29
+
30
+ /* Medium (portrait tablets and large phones, 768px and up) */
31
+ /* @media only screen and (min-width: 768px) { } */
32
+
33
+ /* Large (landscape tablets, 1024px and up) */
34
+ /* @media only screen and (min-width: 1024px) { } */
35
+
36
+ /* XLarge (laptops/desktops, 1366px and up) */
37
+ /* @media only screen and (min-width: 1366px) { } */
38
+
39
+ /* XXlarge (large laptops and desktops, 1920px and up) */
40
+ /* @media only screen and (min-width: 1920px) { } */
@@ -0,0 +1,42 @@
1
+ /********************************************************************
2
+ * Copyright 2025 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 { FunctionComponent } from 'preact';
11
+ import { Skeleton, SkeletonRow } from '@adobe-commerce/elsie/components';
12
+ import '@adobe-commerce/elsie/components/ProductItemCard/ProductItemCardSkeleton.css';
13
+
14
+ export const ProductItemCardSkeleton: FunctionComponent = () => {
15
+ return (
16
+ <div className="dropin-product-item-card dropin-product-item-card-skeleton">
17
+ <Skeleton className="dropin-product-item-card__skeleton dropin-product-item-card__image-container">
18
+ <SkeletonRow
19
+ fullWidth={true}
20
+ className="dropin-product-item-card__skeleton__image"
21
+ />
22
+ <div className="dropin-product-item-card__content dropin-product-item-card__skeleton__content">
23
+ <SkeletonRow
24
+ fullWidth={true}
25
+ size="large"
26
+ className="dropin-product-item-card__skeleton__item"
27
+ />
28
+ <SkeletonRow
29
+ fullWidth={true}
30
+ size="xsmall"
31
+ className="dropin-product-item-card__skeleton__item"
32
+ />
33
+ <SkeletonRow
34
+ fullWidth={true}
35
+ size="small"
36
+ className="dropin-product-item-card__skeleton__item"
37
+ />
38
+ </div>{' '}
39
+ </Skeleton>
40
+ </div>
41
+ );
42
+ };
@@ -0,0 +1,11 @@
1
+ /********************************************************************
2
+ * Copyright 2025 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
+ export * from '@adobe-commerce/elsie/components/ProductItemCard/ProductItemCard';
11
+ export { ProductItemCard as default } from '@adobe-commerce/elsie/components/ProductItemCard/ProductItemCard';
@@ -47,3 +47,4 @@ export * from '@adobe-commerce/elsie/components/Header';
47
47
  export * from '@adobe-commerce/elsie/components/Tag';
48
48
  export * from '@adobe-commerce/elsie/components/ContentGrid';
49
49
  export * from '@adobe-commerce/elsie/components/Pagination';
50
+ export * from '@adobe-commerce/elsie/components/ProductItemCard';
@@ -0,0 +1,58 @@
1
+ import { Meta, Unstyled } from '@storybook/blocks';
2
+
3
+ <Meta title="Utilities/Links" />
4
+ <Unstyled>
5
+
6
+ # Adding Links using the route pattern
7
+
8
+ Whenever possible, avoid placing `onClick` handlers directly on anchor elements (`<a>`) in drop-in components, such as product or category pages, as this results in accessibility issues and broken browser behavior. Problems include:
9
+
10
+ - Right-click > Open in New Tab results in blank pages.
11
+ - Middle-click (open in background tab) won't work as expected.
12
+ - Keyboard navigation and screen readers may not trigger the link correctly.
13
+
14
+ Instead, follow the route pattern to provide composable and accessible navigation.
15
+
16
+ ## How it works
17
+
18
+ Components accept a `routeX` function as a prop. The function receives a data model (a product, for example) and returns a URL. Internally, it's used like this:
19
+
20
+ ```tsx
21
+ <a href={routeProduct?.(product) ?? '#'}>...</a>
22
+ ```
23
+
24
+ This lets developers customize routing logic per storefront while preserving link semantics.
25
+
26
+ ## Example — Component-Side
27
+
28
+ In your component (a PLP item, for example):
29
+
30
+ The `routeProduct` prop must be optional and default to a # or an non-functional element like a `div` if not provided.
31
+
32
+ ```tsx
33
+ type Props = {
34
+ routeProduct?: (product: ProductModel) => string;
35
+ };
36
+
37
+ function ProductCard({ product, routeProduct }: Props) {
38
+ return (
39
+ <a href={routeProduct?.(product) ?? '#'}>
40
+ <div>{product.name}</div>
41
+ </a>
42
+ );
43
+ }
44
+ ```
45
+
46
+ ## Example — Storefront-Side
47
+
48
+ In the storefront integration (`commerce-cart.js` or `commerce-plp.js`, for example):
49
+
50
+ ```js
51
+ import { rootLink } from '@adobe/commerce-url-utils';
52
+
53
+ provider.render(ProductList, {
54
+ routeProduct: (product) => rootLink(`/products/${product.url.urlKey}/${product.topLevelSku}`),
55
+ });
56
+ ```
57
+
58
+ </Unstyled>
@@ -0,0 +1,237 @@
1
+ import { deepmerge } from '../deepmerge';
2
+
3
+ interface ConfigHeaders {
4
+ all?: Record<string, string>;
5
+ [key: string]: Record<string, string> | undefined;
6
+ }
7
+
8
+ interface ConfigPublic {
9
+ default: ConfigRoot;
10
+ [key: string]: ConfigRoot;
11
+ }
12
+
13
+ interface ConfigRoot {
14
+ headers?: ConfigHeaders;
15
+ [key: string]: any;
16
+ }
17
+
18
+ interface Config {
19
+ public: ConfigPublic;
20
+ [key: string]: any;
21
+ }
22
+
23
+ // Private state
24
+ let config: Config | null = null;
25
+ let rootPath: string | null = null;
26
+ let rootConfig: ConfigRoot | null = null;
27
+
28
+ /**
29
+ * Reset the config state
30
+ */
31
+ function resetConfig() {
32
+ config = null;
33
+ rootPath = null;
34
+ rootConfig = null;
35
+ }
36
+
37
+ /**
38
+ * Builds the URL for the config file.
39
+ *
40
+ * @returns {URL} - The URL for the config file.
41
+ */
42
+ function buildConfigURL() {
43
+ return new URL(`${window.location.origin}/config.json`);
44
+ }
45
+
46
+ /**
47
+ * Retrieves a value from a config object using dot notation.
48
+ *
49
+ * @param {Object} obj - The config object.
50
+ * @param {string} key - The key to retrieve (supports dot notation).
51
+ * @returns {any} - The value of the key.
52
+ */
53
+ function getValue(obj: Record<string, any>, key: string): any {
54
+ return key.split('.').reduce((current: Record<string, any>, part: string) => {
55
+ if (!Object.prototype.hasOwnProperty.call(current, part)) {
56
+ console.warn(`Property ${key} does not exist in the object`);
57
+ return undefined;
58
+ }
59
+ return current[part];
60
+ }, obj);
61
+ }
62
+
63
+ /**
64
+ * Get cookie
65
+ * @param {string} cookieName - The name of the cookie to get
66
+ * @returns {string} - The value of the cookie
67
+ */
68
+ function getCookie(cookieName: string): string | undefined {
69
+ const cookies = document.cookie.split(';');
70
+ let foundValue;
71
+
72
+ cookies.forEach((cookie) => {
73
+ const [name, value] = cookie.trim().split('=');
74
+ if (name === cookieName) {
75
+ foundValue = decodeURIComponent(value);
76
+ }
77
+ });
78
+
79
+ return foundValue;
80
+ }
81
+
82
+ /**
83
+ * Get root path
84
+ * @param {Object} [configObj=config] - The config object.
85
+ * @returns {string} - The root path.
86
+ */
87
+ function getRootPath(configObj: Config | null = config): string {
88
+ if (!configObj) {
89
+ console.warn('No config found. Please call initializeConfig() first.');
90
+ return '/';
91
+ }
92
+
93
+ const value = Object.keys(configObj?.public)
94
+ // Sort by number of non-empty segments to find the deepest path
95
+ .sort((a, b) => {
96
+ const aSegments = a.split('/').filter(Boolean).length;
97
+ const bSegments = b.split('/').filter(Boolean).length;
98
+ return bSegments - aSegments;
99
+ })
100
+ .find(
101
+ (key) =>
102
+ window.location.pathname === key ||
103
+ window.location.pathname.startsWith(key)
104
+ );
105
+
106
+ return value ?? '/';
107
+ }
108
+
109
+ /**
110
+ * Get list of root paths from public config
111
+ * @returns {Array} - The list of root paths.
112
+ */
113
+ function getListOfRootPaths(): string[] {
114
+ if (!config) {
115
+ console.warn('No config found. Please call initializeConfig() first.');
116
+ return [];
117
+ }
118
+
119
+ return Object.keys(config.public).filter((root) => root !== 'default');
120
+ }
121
+
122
+ /**
123
+ * Checks if the public config contains more than "default"
124
+ * @returns {boolean} - true if public config contains more than "default"
125
+ */
126
+ function isMultistore(): boolean {
127
+ return getListOfRootPaths().length >= 1;
128
+ }
129
+
130
+ /**
131
+ * Retrieves headers from config entries like commerce.headers.pdp.my-header, etc and
132
+ * returns as object of all headers like { my-header: value, ... }
133
+ * @param {string} scope - The scope of the headers to retrieve.
134
+ * @returns {Object} - The headers.
135
+ */
136
+ function getHeaders(scope: string): Record<string, string> {
137
+ if (!rootConfig) {
138
+ throw new Error(
139
+ 'Configuration not initialized. Call initializeConfig() first.'
140
+ );
141
+ }
142
+ const headers = rootConfig.headers ?? {};
143
+ return {
144
+ ...(headers.all ?? {}),
145
+ ...(headers[scope] ?? {}),
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Applies config overrides from metadata.
151
+ *
152
+ * @param {Object} [configObj=config] - The base config.
153
+ * @param {string} [root=rootPath] - The root path.
154
+ * @returns {Object} - The config with overrides applied.
155
+ */
156
+ function applyConfigOverrides(
157
+ configObj: Config | null,
158
+ root: string | null
159
+ ): ConfigRoot {
160
+ const defaultConfig = configObj!.public?.default;
161
+
162
+ if (root === '/' || !configObj!.public[root as keyof ConfigPublic])
163
+ return defaultConfig;
164
+
165
+ return deepmerge(
166
+ defaultConfig,
167
+ configObj!.public[root as keyof ConfigPublic]
168
+ );
169
+ }
170
+
171
+ /**
172
+ * Fetches config from remote and saves in session, then returns it, otherwise
173
+ * returns if it already exists.
174
+ *
175
+ * @returns {Promise<Object>} - The config JSON from session storage
176
+ */
177
+ async function getConfigFromSession(): Promise<Config> {
178
+ try {
179
+ const configJSON = window.sessionStorage.getItem('config');
180
+ if (!configJSON) {
181
+ throw new Error('No config in session storage');
182
+ }
183
+
184
+ const parsedConfig = JSON.parse(configJSON);
185
+ if (
186
+ !parsedConfig[':expiry'] ||
187
+ parsedConfig[':expiry'] < Math.round(Date.now() / 1000)
188
+ ) {
189
+ throw new Error('Config expired');
190
+ }
191
+ return parsedConfig;
192
+ } catch (e) {
193
+ const config = await fetch(buildConfigURL());
194
+ if (!config.ok) throw new Error('Failed to fetch config');
195
+ const configJSON = await config.json();
196
+ configJSON[':expiry'] = Math.round(Date.now() / 1000) + 7200;
197
+ window.sessionStorage.setItem('config', JSON.stringify(configJSON));
198
+ return configJSON;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Initializes the configuration system.
204
+ * @returns {Promise<void>}
205
+ */
206
+ async function initializeConfig(): Promise<ConfigRoot> {
207
+ config = await getConfigFromSession();
208
+ rootPath = getRootPath(config);
209
+ rootConfig = applyConfigOverrides(config, rootPath);
210
+ return rootConfig;
211
+ }
212
+
213
+ /**
214
+ * Retrieves a configuration value.
215
+ *
216
+ * @param {string} configParam - The configuration parameter to retrieve.
217
+ * @returns {string|undefined} - The value of the configuration parameter, or undefined.
218
+ */
219
+ function getConfigValue(configParam: string): any {
220
+ if (!rootConfig) {
221
+ throw new Error(
222
+ 'Configuration not initialized. Call initializeConfig() first.'
223
+ );
224
+ }
225
+ return getValue(rootConfig, configParam);
226
+ }
227
+
228
+ export {
229
+ initializeConfig,
230
+ getCookie,
231
+ getRootPath,
232
+ getListOfRootPaths,
233
+ isMultistore,
234
+ getConfigValue,
235
+ getHeaders,
236
+ resetConfig,
237
+ };