@adobe-commerce/elsie 1.5.0-beta3 → 1.5.0-beta5

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.
@@ -0,0 +1,249 @@
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, Fragment } from 'preact';
11
+ import { HTMLAttributes } from 'preact/compat';
12
+ import { classes, VComponent } from '@adobe-commerce/elsie/lib';
13
+ import {
14
+ Icon,
15
+ Button,
16
+ Skeleton,
17
+ SkeletonRow,
18
+ } from '@adobe-commerce/elsie/components';
19
+ import { useText } from '@adobe-commerce/elsie/i18n';
20
+
21
+ import '@adobe-commerce/elsie/components/Table/Table.css';
22
+
23
+ type Sortable = 'asc' | 'desc' | true;
24
+
25
+ type Column =
26
+ | { label: string; key: string; ariaLabel?: string; sortBy?: Sortable }
27
+ | {
28
+ label: VNode<HTMLAttributes<HTMLElement>>;
29
+ key: string;
30
+ ariaLabel: string;
31
+ sortBy?: Sortable;
32
+ };
33
+
34
+ type RowData = {
35
+ [key: string]: VNode | string | number | undefined;
36
+ _rowDetails?: VNode | string; // Special property for expandable row content
37
+ };
38
+
39
+ export interface TableProps
40
+ extends Omit<HTMLAttributes<HTMLTableElement>, 'loading'> {
41
+ columns: Column[];
42
+ rowData: RowData[];
43
+ mobileLayout?: 'stacked' | 'none';
44
+ caption?: string;
45
+ expandedRows?: Set<number>;
46
+ loading?: boolean;
47
+ skeletonRowCount?: number;
48
+ onSortChange?: (columnKey: string, direction: Sortable) => void;
49
+ }
50
+
51
+ export const Table: FunctionComponent<TableProps> = ({
52
+ className,
53
+ children,
54
+ columns = [],
55
+ rowData = [],
56
+ mobileLayout = 'none',
57
+ caption,
58
+ expandedRows = new Set(),
59
+ loading = false,
60
+ skeletonRowCount = 10,
61
+ onSortChange,
62
+ ...props
63
+ }) => {
64
+ const translations = useText({
65
+ sortedAscending: 'Dropin.Table.sortedAscending',
66
+ sortedDescending: 'Dropin.Table.sortedDescending',
67
+ sortBy: 'Dropin.Table.sortBy',
68
+ });
69
+
70
+ const handleSort = (column: Column) => {
71
+ if (!onSortChange) return;
72
+
73
+ // Determine next sort direction
74
+ let nextDirection: Sortable;
75
+ if (column.sortBy === true) {
76
+ nextDirection = 'asc';
77
+ } else if (column.sortBy === 'asc') {
78
+ nextDirection = 'desc';
79
+ } else {
80
+ nextDirection = true;
81
+ }
82
+
83
+ onSortChange(column.key, nextDirection);
84
+ };
85
+
86
+ const renderSortButton = (column: Column) => {
87
+ if (column.sortBy === undefined) return null;
88
+ const label = column.ariaLabel ?? (column.label as string);
89
+
90
+ let iconSource: string;
91
+ let ariaLabel: string;
92
+
93
+ if (column.sortBy === 'asc') {
94
+ iconSource = 'ChevronUp';
95
+ ariaLabel = translations.sortedAscending.replace('{label}', label);
96
+ } else if (column.sortBy === 'desc') {
97
+ iconSource = 'ChevronDown';
98
+ ariaLabel = translations.sortedDescending.replace('{label}', label);
99
+ } else {
100
+ iconSource = 'Sort';
101
+ ariaLabel = translations.sortBy.replace('{label}', label);
102
+ }
103
+
104
+ return (
105
+ <Button
106
+ variant="tertiary"
107
+ size="medium"
108
+ className="dropin-table__header__sort-button"
109
+ icon={<Icon source={iconSource} />}
110
+ aria-label={ariaLabel}
111
+ onClick={() => handleSort(column)}
112
+ />
113
+ );
114
+ };
115
+
116
+ const renderSkeletonRows = () => {
117
+ return Array.from({ length: skeletonRowCount }, (_, rowIndex) => (
118
+ <tr key={`skeleton-${rowIndex}`} className="dropin-table__body__row">
119
+ {columns.map((column) => (
120
+ <td
121
+ key={column.key}
122
+ className="dropin-table__body__cell"
123
+ data-label={column.ariaLabel ?? column.label}
124
+ >
125
+ <Skeleton>
126
+ <SkeletonRow variant="row" size="small" fullWidth />
127
+ </Skeleton>
128
+ </td>
129
+ ))}
130
+ </tr>
131
+ ));
132
+ };
133
+
134
+ const renderDataRows = () => {
135
+ return rowData.map((row, rowIndex) => {
136
+ const hasDetails = row._rowDetails !== undefined;
137
+ const isExpanded = expandedRows.has(rowIndex);
138
+
139
+ return (
140
+ <Fragment key={rowIndex}>
141
+ <tr className="dropin-table__body__row">
142
+ {columns.map((column) => {
143
+ const cell = row[column.key];
144
+ const label = column.ariaLabel ?? column.label;
145
+
146
+ if (typeof cell === 'string' || typeof cell === 'number') {
147
+ return (
148
+ <td
149
+ key={column.key}
150
+ className="dropin-table__body__cell"
151
+ data-label={label}
152
+ >
153
+ {cell}
154
+ </td>
155
+ );
156
+ }
157
+
158
+ return (
159
+ <td
160
+ key={column.key}
161
+ className="dropin-table__body__cell"
162
+ data-label={label}
163
+ >
164
+ <VComponent node={cell!} />
165
+ </td>
166
+ );
167
+ })}
168
+ </tr>
169
+ {hasDetails && isExpanded && (
170
+ <tr
171
+ key={`${rowIndex}-details`}
172
+ className="dropin-table__row-details dropin-table__row-details--expanded"
173
+ id={`row-${rowIndex}-details`}
174
+ >
175
+ <td
176
+ className="dropin-table__row-details__cell"
177
+ colSpan={columns.length}
178
+ role="region"
179
+ aria-labelledby={`row-${rowIndex}-details`}
180
+ >
181
+ {typeof row._rowDetails === 'string' ? (
182
+ row._rowDetails
183
+ ) : (
184
+ <VComponent node={row._rowDetails!} />
185
+ )}
186
+ </td>
187
+ </tr>
188
+ )}
189
+ </Fragment>
190
+ );
191
+ });
192
+ };
193
+
194
+ const getAriaSort = (
195
+ column: Column
196
+ ): 'none' | 'ascending' | 'descending' | 'other' | undefined => {
197
+ if (column.sortBy === true) return 'none';
198
+ if (column.sortBy === 'asc') return 'ascending';
199
+ if (column.sortBy === 'desc') return 'descending';
200
+ return undefined;
201
+ };
202
+
203
+ return (
204
+ <div
205
+ className={classes([
206
+ 'dropin-table',
207
+ `dropin-table--mobile-layout-${mobileLayout}`,
208
+ className,
209
+ ])}
210
+ >
211
+ <table {...props} className="dropin-table__table">
212
+ {caption && (
213
+ <caption className="dropin-table__caption">{caption}</caption>
214
+ )}
215
+ <thead className="dropin-table__header">
216
+ <tr className="dropin-table__header__row">
217
+ {columns.map((column) => (
218
+ <th
219
+ key={column.key}
220
+ className={classes([
221
+ 'dropin-table__header__cell',
222
+ [
223
+ 'dropin-table__header__cell--sorted',
224
+ column.sortBy === 'asc' || column.sortBy === 'desc',
225
+ ],
226
+ [
227
+ 'dropin-table__header__cell--sortable',
228
+ column.sortBy !== undefined,
229
+ ],
230
+ ])}
231
+ aria-sort={getAriaSort(column)}
232
+ >
233
+ {column.label}
234
+ {renderSortButton(column)}
235
+ </th>
236
+ ))}
237
+ </tr>
238
+ </thead>
239
+ <tbody className="dropin-table__body">
240
+ {loading
241
+ ? // Render skeleton rows when loading
242
+ renderSkeletonRows()
243
+ : // Render actual data when not loading
244
+ renderDataRows()}
245
+ </tbody>
246
+ </table>
247
+ </div>
248
+ );
249
+ };
@@ -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/Table/Table';
11
+ export { Table as default } from '@adobe-commerce/elsie/components/Table/Table';
@@ -34,7 +34,19 @@
34
34
  border: var(--shape-border-width-1) solid var(--color-neutral-800);
35
35
  }
36
36
 
37
- .dropin-toggle-button:has(input:focus-visible) {
37
+ /* Disabled */
38
+ .dropin-toggle-button__disabled .dropin-toggle-button__actionButton {
39
+ cursor: default;
40
+ background-color: var(--color-neutral-300);
41
+ border: var(--shape-border-width-1) solid var(--color-neutral-500);
42
+ }
43
+
44
+ .dropin-toggle-button__disabled .dropin-toggle-button__content {
45
+ color: var(--color-neutral-500);
46
+ cursor: default;
47
+ }
48
+
49
+ .dropin-toggle-button:not(.dropin-toggle-button__disabled):has(input:focus-visible) {
38
50
  outline: 0 none;
39
51
  box-shadow: 0 0 0 var(--shape-icon-stroke-4) var(--color-neutral-400);
40
52
  -webkit-box-shadow: 0 0 0 var(--shape-icon-stroke-4) var(--color-neutral-400);
@@ -79,6 +79,15 @@ const meta: Meta<ToggleButtonProps> = {
79
79
  type: 'boolean',
80
80
  },
81
81
  },
82
+ disabled: {
83
+ description: 'Whether or not the Toggle button is disabled',
84
+ type: {
85
+ name: 'boolean',
86
+ },
87
+ control: {
88
+ type: 'boolean',
89
+ },
90
+ },
82
91
  onChange: {
83
92
  description: 'Function to be called when the Toggle button is clicked',
84
93
  type: {
@@ -103,11 +112,9 @@ export const ToggleButtonStory: Story = {
103
112
  },
104
113
  play: async ({ canvasElement }) => {
105
114
  const canvas = within(canvasElement);
106
- const toggleButton = document.querySelector('.dropin-toggle-button');
107
115
  const toggleButtonInput = await canvas.findByRole('radio');
108
- const toggleButtonText = document.querySelector(
109
- '.dropin-toggle-button__content'
110
- );
116
+ const toggleButton = toggleButtonInput.closest('.dropin-toggle-button');
117
+ const toggleButtonText = toggleButton?.querySelector('.dropin-toggle-button__content');
111
118
  await expect(toggleButton).toHaveClass('dropin-toggle-button__selected');
112
119
  await expect(toggleButtonText).toHaveTextContent('Toggle Button label');
113
120
  await expect(toggleButtonInput).toBeChecked();
@@ -124,9 +131,9 @@ export const ToggleButtonNotSelected: Story = {
124
131
  },
125
132
  play: async ({ canvasElement }) => {
126
133
  const canvas = within(canvasElement);
127
- const toggleButton = document.querySelector('.dropin-toggle-button');
128
134
  const toggleButtonInput = await canvas.findByRole('radio');
129
- const toggleButtonText = await canvas.findByText('Toggle Button label');
135
+ const toggleButton = toggleButtonInput.closest('.dropin-toggle-button');
136
+ const toggleButtonText = toggleButton?.querySelector('.dropin-toggle-button__content');
130
137
  await expect(toggleButton).not.toHaveClass(
131
138
  'dropin-toggle-button__selected'
132
139
  );
@@ -19,6 +19,7 @@ export interface ToggleButtonProps
19
19
  name: string;
20
20
  value: string;
21
21
  busy?: boolean;
22
+ disabled?: boolean;
22
23
  icon?:
23
24
  | VNode<HTMLAttributes<SVGSVGElement>>
24
25
  | VNode<HTMLAttributes<HTMLImageElement>>;
@@ -31,6 +32,7 @@ export const ToggleButton: FunctionComponent<ToggleButtonProps> = ({
31
32
  name,
32
33
  value,
33
34
  busy = false,
35
+ disabled = false,
34
36
  children,
35
37
  className,
36
38
  icon,
@@ -45,6 +47,7 @@ export const ToggleButton: FunctionComponent<ToggleButtonProps> = ({
45
47
  'dropin-toggle-button',
46
48
  className,
47
49
  ['dropin-toggle-button__selected', selected],
50
+ ['dropin-toggle-button__disabled', disabled],
48
51
  ])}
49
52
  >
50
53
  <label className="dropin-toggle-button__actionButton">
@@ -53,6 +56,7 @@ export const ToggleButton: FunctionComponent<ToggleButtonProps> = ({
53
56
  name={name}
54
57
  value={value}
55
58
  checked={selected}
59
+ disabled={disabled}
56
60
  onChange={() => onChange && onChange(value)}
57
61
  aria-label={name}
58
62
  busy={busy}
@@ -2,9 +2,9 @@
2
2
  * Copyright 2024 Adobe
3
3
  * All Rights Reserved.
4
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.
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
8
  *******************************************************************/
9
9
 
10
10
  export * from '@adobe-commerce/elsie/components/Skeleton';
@@ -49,3 +49,5 @@ export * from '@adobe-commerce/elsie/components/ContentGrid';
49
49
  export * from '@adobe-commerce/elsie/components/Pagination';
50
50
  export * from '@adobe-commerce/elsie/components/ProductItemCard';
51
51
  export * from '@adobe-commerce/elsie/components/InputFile';
52
+ export * from '@adobe-commerce/elsie/components/Table';
53
+ export * from '@adobe-commerce/elsie/components/MultiSelect';
@@ -19,6 +19,7 @@ The context is defined during implementation of a drop-in and can be used to pas
19
19
  - **prependChild**: A function to prepend a new HTML element to the slot's content.
20
20
  - **appendSibling**: A function to append a new HTML element after the slot's content.
21
21
  - **prependSibling**: A function to prepend a new HTML element **before** the slot's content.
22
+ - **remove**: A function to remove the slot element from the DOM.
22
23
  - **getSlotElement**: A function to get a slot element.
23
24
  - **onChange**: A function to listen to changes in the slot's context.
24
25
 
@@ -150,6 +151,7 @@ provider.render(MyContainer, {
150
151
  // ctx.prependChild(element);
151
152
  // ctx.appendSibling(element);
152
153
  // ctx.prependSibling(element);
154
+ // ctx.remove();
153
155
 
154
156
  // to listen and react to changes in the slot's context (lifecycle)
155
157
  ctx.onChange((next) => {
@@ -141,6 +141,44 @@
141
141
  },
142
142
  "InputDate": {
143
143
  "picker": "Select a date"
144
+ },
145
+ "Table": {
146
+ "sortedAscending": "Sort {label} ascending",
147
+ "sortedDescending": "Sort {label} descending",
148
+ "sortBy": "Sort by {label}"
149
+ },
150
+ "MultiSelect": {
151
+ "selectAll": "Select All",
152
+ "deselectAll": "Deselect All",
153
+ "placeholder": "Select options",
154
+ "noResultsText": "No options available",
155
+ "ariaLabel":{
156
+ "removed": "removed",
157
+ "added": "added",
158
+ "itemsSelected": "items selected",
159
+ "itemsAdded": "items added",
160
+ "itemsRemoved": "items removed",
161
+ "selectedTotal": "selected total",
162
+ "noResultsFor": "No results found for",
163
+ "optionsAvailable": "options available",
164
+ "dropdownExpanded": "Dropdown expanded",
165
+ "useArrowKeys": "Use arrow keys to navigate",
166
+ "removeFromSelection": "Remove",
167
+ "fromSelection": "from selection",
168
+ "selectedItem": "Selected item:",
169
+ "inField": " in {floatingLabel}",
170
+ "selectedItems": "Selected items",
171
+ "scrollableOptionsList": "Scrollable options list",
172
+ "selectOptions": "Select options",
173
+ "itemAction": "{label} {action}. {count} items selected.",
174
+ "bulkAdded": "{count} items added. {total} items selected total.",
175
+ "bulkRemoved": "{count} items removed. {total} items selected total.",
176
+ "dropdownExpandedWithOptions": "Dropdown expanded. {count} option{s} available. Use arrow keys to navigate.",
177
+ "selectedItemInField": "Selected item: {label} in {field}",
178
+ "removeFromSelectionWithText": "Remove {label} from selection. {text}",
179
+ "itemsSelectedDescription": "{count} item{s} selected: {labels}",
180
+ "noItemsSelected": "No items selected"
181
+ }
144
182
  }
145
183
  }
146
184
  }
@@ -40,7 +40,7 @@ function resetConfig() {
40
40
  * @param {Object} [configObj=config] - The config object.
41
41
  * @returns {string} - The root path.
42
42
  */
43
- function getRootPath(configObj: Config | null = config): string {
43
+ function getRootPath(configObj: Config | null = config, options?: { match?: (key: string) => boolean }): string {
44
44
  if (!configObj) {
45
45
  console.warn('No config found. Please call initializeConfig() first.');
46
46
  return '/';
@@ -56,7 +56,7 @@ function getRootPath(configObj: Config | null = config): string {
56
56
  .find(
57
57
  (key) =>
58
58
  window.location.pathname === key ||
59
- window.location.pathname.startsWith(key)
59
+ (options?.match?.(key) ?? window.location.pathname.startsWith(key))
60
60
  );
61
61
 
62
62
  return value ?? '/';
@@ -126,11 +126,14 @@ function applyConfigOverrides(
126
126
 
127
127
  /**
128
128
  * Initializes the configuration system.
129
+ * @param {Object} configObj - The config object.
130
+ * @param {Object} [options] - The options object.
131
+ * @param {Function} [options.match] - The function to match the path to the config.
129
132
  * @returns {Object} The initialized root configuration
130
133
  */
131
- function initializeConfig(configObj: Config): ConfigRoot {
134
+ function initializeConfig(configObj: Config, options?: { match?: (key: string) => boolean }): ConfigRoot {
132
135
  config = configObj;
133
- rootPath = getRootPath(config);
136
+ rootPath = getRootPath(config, { match: options?.match });
134
137
  rootConfig = applyConfigOverrides(config, rootPath);
135
138
  return rootConfig;
136
139
  }
package/src/lib/slot.tsx CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  RefObject,
16
16
  VNode,
17
17
  } from 'preact';
18
- import { HTMLAttributes } from 'preact/compat';
18
+ import { Children, HTMLAttributes, isValidElement } from 'preact/compat';
19
19
  import {
20
20
  StateUpdater,
21
21
  useCallback,
@@ -41,6 +41,7 @@ interface SlotElement {
41
41
  prependChild: MutateElement;
42
42
  appendSibling: MutateElement;
43
43
  prependSibling: MutateElement;
44
+ remove: () => void;
44
45
  }
45
46
 
46
47
  interface PrivateContext<T> {
@@ -60,6 +61,7 @@ interface DefaultSlotContext<T> extends PrivateContext<T> {
60
61
  prependChild: MutateElement;
61
62
  appendSibling: MutateElement;
62
63
  prependSibling: MutateElement;
64
+ remove: () => void;
63
65
  onRender: (cb: (next: T & DefaultSlotContext<T>) => void) => void;
64
66
  onChange: (cb: (next: T & DefaultSlotContext<T>) => void) => void;
65
67
  }
@@ -85,7 +87,7 @@ export function useSlot<K, V extends HTMLElement>(
85
87
  render?: Function,
86
88
  // eslint-disable-next-line no-undef
87
89
  contentTag: keyof HTMLElementTagNameMap = 'div'
88
- ): [RefObject<V>, Record<string, any>] {
90
+ ): [RefObject<V>, Record<string, any>, 'loading' | 'pending' | 'ready'] {
89
91
  const slotsQueue = useContext(SlotQueueContext);
90
92
 
91
93
  // HTML Element
@@ -207,6 +209,10 @@ export function useSlot<K, V extends HTMLElement>(
207
209
  const parent = element.parentNode;
208
210
  parent?.insertBefore(elem, element);
209
211
  },
212
+
213
+ remove: () => {
214
+ element.remove();
215
+ },
210
216
  };
211
217
  },
212
218
  [name]
@@ -301,6 +307,14 @@ export function useSlot<K, V extends HTMLElement>(
301
307
  [_registerMethod]
302
308
  );
303
309
 
310
+ // @ts-ignore
311
+ context.remove = useCallback(() => {
312
+ // @ts-ignore
313
+ _registerMethod(() => {
314
+ elementRef.current?.remove();
315
+ });
316
+ }, [_registerMethod]);
317
+
304
318
  const handleLifeCycleRender = useCallback(async () => {
305
319
  if (status.current === 'loading') return;
306
320
 
@@ -363,9 +377,51 @@ export function useSlot<K, V extends HTMLElement>(
363
377
  // eslint-disable-next-line react-hooks/exhaustive-deps
364
378
  }, [JSON.stringify(context), JSON.stringify(_state)]);
365
379
 
366
- return [elementRef, props];
380
+ return [elementRef, props, status.current];
367
381
  }
368
382
 
383
+ /**
384
+ * Recursively processes children elements to conditionally prevent image loading.
385
+ *
386
+ * This function traverses the children tree and modifies img elements to prevent
387
+ * premature loading during slot initialization. When isReady is false, img src
388
+ * attributes are set to empty string to prevent network requests, while preserving
389
+ * the original src in a data attribute for debugging purposes.
390
+ *
391
+ * @param children - The children elements to process (can be any React/Preact children)
392
+ * @param isReady - Whether the slot is ready to load images (true) or should prevent loading (false)
393
+ * @returns Processed children with conditional image src attributes
394
+ */
395
+ const processChildren = (children: any, isReady: boolean): any => {
396
+ return Children.map(children, child => {
397
+ // Handle text nodes, numbers, etc.
398
+ if (!isValidElement(child)) {
399
+ return child;
400
+ }
401
+
402
+ // Handle img elements - conditionally set src
403
+ if (child.props.src) {
404
+ return cloneElement(child, {
405
+ ...child.props,
406
+ src: isReady ? child.props.src : "",
407
+ // Optionally preserve original src in data attribute for debugging
408
+ 'data-original-src': child.props.src,
409
+ });
410
+ }
411
+
412
+ // Handle elements with children - recursively process them
413
+ if (child.props && child.props.children) {
414
+ return cloneElement(child, {
415
+ ...child.props,
416
+ children: processChildren(child.props.children, isReady),
417
+ });
418
+ }
419
+
420
+ // Return other elements as-is
421
+ return child;
422
+ });
423
+ };
424
+
369
425
  // Slot Component
370
426
  interface SlotPropsComponent<T>
371
427
  extends Omit<HTMLAttributes<HTMLElement>, 'slot'> {
@@ -398,7 +454,7 @@ export function Slot<T>({
398
454
  }> {
399
455
  const slotsQueue = useContext(SlotQueueContext);
400
456
 
401
- const [elementRef, slotProps] = useSlot<T, HTMLElement>(
457
+ const [elementRef, slotProps, status] = useSlot<T, HTMLElement>(
402
458
  name,
403
459
  context,
404
460
  slot,
@@ -426,7 +482,7 @@ export function Slot<T>({
426
482
  ref: elementRef,
427
483
  'data-slot': name,
428
484
  },
429
- slotProps.children
485
+ processChildren(slotProps.children, status === 'ready')
430
486
  );
431
487
  }
432
488