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

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-alpha014",
4
4
  "license": "SEE LICENSE IN LICENSE.md",
5
5
  "description": "Domain Package SDK",
6
6
  "engines": {
@@ -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
  }
@@ -14,8 +14,13 @@ import * as Icons from '@adobe-commerce/elsie/icons';
14
14
 
15
15
  /**
16
16
  * Use Icons as symbols or metaphors to communicate and enhance the user experience.
17
+ *
18
+ * The Icon component supports three source types:
19
+ * - Direct component imports
20
+ * - Icon names from the built-in icon set
21
+ * - SVGs from URLs (supports URLs that match the host domain)
17
22
  */
18
- const meta: Meta<IconProps> = {
23
+ const meta: Meta<StoryIconProps> = {
19
24
  title: 'Components/Icon',
20
25
  component: Icon,
21
26
  argTypes: {
@@ -25,6 +30,13 @@ const meta: Meta<IconProps> = {
25
30
  control: {
26
31
  type: 'select',
27
32
  },
33
+ description: 'Select a built-in icon',
34
+ },
35
+ url: {
36
+ control: {
37
+ type: 'text',
38
+ },
39
+ description: 'Or enter a URL to an external SVG (this takes priority over icon selection)',
28
40
  },
29
41
  size: {
30
42
  control: 'select',
@@ -33,6 +45,7 @@ const meta: Meta<IconProps> = {
33
45
  stroke: {
34
46
  control: 'select',
35
47
  options: ['1', '2', '3', '4'],
48
+ description: 'Stroke width. Works only for stroke-based icons.',
36
49
  },
37
50
  title: {
38
51
  control: 'text',
@@ -43,7 +56,11 @@ const meta: Meta<IconProps> = {
43
56
 
44
57
  export default meta;
45
58
 
46
- type Story = StoryObj<IconProps>;
59
+ type Story = StoryObj<StoryIconProps>;
60
+
61
+ interface StoryIconProps extends IconProps {
62
+ url?: string;
63
+ }
47
64
 
48
65
  /**
49
66
  * ```ts
@@ -52,23 +69,26 @@ type Story = StoryObj<IconProps>;
52
69
  */
53
70
 
54
71
  export const Primary: Story = {
72
+ render: ({ url, source, ...args }: StoryIconProps) => {
73
+ const iconSource = url || source;
74
+ return <Icon {...args} source={iconSource as any} />;
75
+ },
55
76
  args: {
56
77
  source: Cart,
57
78
  },
58
79
  };
59
80
 
60
81
  export const Lazy: Story = {
61
- argTypes: {
62
- source: {
63
- mapping: Object.keys(Icons),
64
- },
82
+ render: ({ url, source, ...args }: StoryIconProps) => {
83
+ const iconSource = url || source;
84
+ return <Icon {...args} source={iconSource as any} />;
65
85
  },
66
86
  args: {
67
87
  source: 'Cart',
68
88
  },
69
89
  };
70
90
 
71
- export const AllIcons: Story = {
91
+ export const AllBuiltInIcons: Story = {
72
92
  argTypes: {
73
93
  style: Object
74
94
  },
@@ -96,3 +116,72 @@ export const AllIcons: Story = {
96
116
  </div>
97
117
  ),
98
118
  };
119
+
120
+
121
+ export const UrlExamples: Story = {
122
+ render: ({ url, ...args }: StoryIconProps) => (
123
+ <div style={{
124
+ display: 'grid',
125
+ gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
126
+ gap: '2rem',
127
+ padding: '1rem'
128
+ }}>
129
+ <div style={{
130
+ border: '1px solid #e1e5e9',
131
+ borderRadius: '8px',
132
+ padding: '1.5rem',
133
+ textAlign: 'center',
134
+ backgroundColor: '#f8f9fa'
135
+ }}>
136
+ <h3 style={{ margin: '0 0 1rem 0', color: '#2c3e50' }}>✅ Valid URL</h3>
137
+ <Icon
138
+ source={url || `${window.location.origin}/favicon.svg`}
139
+ size="12"
140
+ title="logo icon from common domain"
141
+ aria-label="Star icon loaded from external URL"
142
+ {...args}
143
+ />
144
+ <p style={{
145
+ fontSize: '12px',
146
+ color: '#495057',
147
+ margin: '0.5rem 0 0 0',
148
+ wordBreak: 'break-all'
149
+ }}>
150
+ {url ? `Displays icon from: ${url}` : 'Displays icon from SVG'}
151
+ </p>
152
+ </div>
153
+
154
+ <div style={{
155
+ border: '1px solid #f8d7da',
156
+ borderRadius: '8px',
157
+ padding: '1.5rem',
158
+ textAlign: 'center',
159
+ backgroundColor: '#f8d7da'
160
+ }}>
161
+ <h3 style={{ margin: '0 0 1rem 0', color: '#721c24' }}>❌ Invalid URL</h3>
162
+ <Icon
163
+ source="https://invalid-url.com/icon.svg"
164
+ size="32"
165
+ title="Failed to load icon"
166
+ aria-label="Icon that failed to load"
167
+ />
168
+ <p style={{
169
+ fontSize: '12px',
170
+ color: '#721c24',
171
+ margin: '0.5rem 0 0 0',
172
+ wordBreak: 'break-all'
173
+ }}>
174
+ Shows empty SVG
175
+ </p>
176
+ </div>
177
+
178
+ </div>
179
+ ),
180
+ parameters: {
181
+ docs: {
182
+ description: {
183
+ story: 'Examples of different URL formats supported by the Icon component.',
184
+ },
185
+ },
186
+ },
187
+ };
@@ -10,6 +10,7 @@
10
10
  import { FunctionComponent } from 'preact';
11
11
  import { classes } from '@adobe-commerce/elsie/lib/classes';
12
12
  import { lazy, Suspense, SVGProps } from 'preact/compat';
13
+ import { useState, useEffect } from 'preact/hooks';
13
14
 
14
15
  import '@adobe-commerce/elsie/components/Icon/Icon.css';
15
16
 
@@ -66,9 +67,10 @@ const lazyIcons = {
66
67
  };
67
68
 
68
69
  export interface IconProps extends Omit<SVGProps<SVGSVGElement>, 'size'> {
69
- source:
70
- | FunctionComponent<SVGProps<SVGSVGElement> & { title?: string }>
71
- | IconType;
70
+ source?:
71
+ | FunctionComponent<SVGProps<SVGSVGElement> & { title?: string }>
72
+ | IconType
73
+ | string;
72
74
  size?: '12' | '16' | '24' | '32' | '64' | '80';
73
75
  stroke?: '1' | '2' | '3' | '4';
74
76
  className?: string;
@@ -79,6 +81,115 @@ export type IconNode = FunctionComponent<
79
81
  SVGProps<SVGSVGElement> & { title?: string }
80
82
  >;
81
83
 
84
+ function isValidUrl(source: string): boolean { // check for URL from same domain
85
+ try {
86
+ if (source.startsWith('//')) {
87
+ const absoluteUrl = `${window.location.protocol}${source}`;
88
+ const url = new URL(absoluteUrl);
89
+ return url.hostname === window.location.hostname;
90
+ }
91
+ const url = new URL(source);
92
+
93
+ if (url.hostname !== window.location.hostname) {
94
+ console.error(`[Icon] External URL rejected for security: ${source} - Only same-domain URLs are allowed`);
95
+ return false;
96
+ }
97
+
98
+ return true;
99
+ } catch {
100
+ console.error(`[Icon] Invalid URL format: ${source}`);
101
+ return false;
102
+ }
103
+ }
104
+
105
+ function UrlSvgLoader({
106
+ url,
107
+ ...props
108
+ }: SVGProps<SVGSVGElement> & { url: string }) {
109
+ const [svgContent, setSvgContent] = useState<string>('');
110
+ const [loading, setLoading] = useState(true);
111
+ const [error, setError] = useState(false);
112
+
113
+ useEffect(() => {
114
+ fetch(url)
115
+ .then(response => {
116
+ if (!response.ok) {
117
+ console.error(`[Icon] Failed to fetch SVG: ${response.status} ${response.statusText}`);
118
+ throw error;
119
+ }
120
+ return response.text();
121
+ })
122
+ .then(content => {
123
+ // Check if content is valid SVG
124
+ if (!content.trim().toLowerCase().startsWith('<?xml') &&
125
+ !content.trim().toLowerCase().startsWith('<svg')) {
126
+ console.error(`[Icon] Invalid SVG content from ${url} - Content must be a valid SVG file`);
127
+ setError(true);
128
+ setLoading(false);
129
+ return;
130
+ }
131
+
132
+ // Process SVG content to ensure proper sizing and accessibility
133
+ let processedContent = content;
134
+
135
+ if (props.width) {
136
+ processedContent = processedContent.replace(
137
+ /<svg([^>]*)\s+width\s*=\s*["'][^"']*["']/gi,
138
+ '<svg$1'
139
+ );
140
+ processedContent = processedContent.replace(
141
+ /<svg/i,
142
+ `<svg width="${props.width}"`
143
+ );
144
+ }
145
+
146
+ if (props.height) {
147
+ processedContent = processedContent.replace(
148
+ /<svg([^>]*)\s+height\s*=\s*["'][^"']*["']/gi,
149
+ '<svg$1'
150
+ );
151
+ processedContent = processedContent.replace(
152
+ /<svg/i,
153
+ `<svg height="${props.height}"`
154
+ );
155
+ }
156
+
157
+ if (props.title) {
158
+ processedContent = processedContent.replace(/<title[^>]*>.*?<\/title>/gi, '');
159
+ processedContent = processedContent.replace(
160
+ /<svg([^>]*)>/i,
161
+ `<svg$1><title>${props.title}</title>`
162
+ );
163
+ }
164
+
165
+ setSvgContent(processedContent);
166
+ setLoading(false);
167
+ })
168
+ .catch((error) => {
169
+ console.error(`[Icon] ${error.message}`);
170
+ setError(true);
171
+ setLoading(false);
172
+ });
173
+ }, [url, props.width, props.height, props.title]);
174
+
175
+ if (loading || error) {
176
+ return <svg {...props} />;
177
+ }
178
+
179
+ return (
180
+ <div
181
+ className={props.className}
182
+ style={{
183
+ width: String(props.width),
184
+ height: String(props.height),
185
+ display: 'inline-block',
186
+ lineHeight: 0,
187
+ }}
188
+ dangerouslySetInnerHTML={{ __html: svgContent }}
189
+ />
190
+ );
191
+ }
192
+
82
193
  export function Icon({
83
194
  source: Source,
84
195
  size = '24',
@@ -87,7 +198,6 @@ export function Icon({
87
198
  className,
88
199
  ...props
89
200
  }: IconProps) {
90
- const LazyIcon = typeof Source === 'string' ? lazyIcons[Source] : null;
91
201
 
92
202
  const defaultProps = {
93
203
  className: classes([
@@ -100,10 +210,27 @@ export function Icon({
100
210
  viewBox,
101
211
  };
102
212
 
213
+ if (typeof Source === 'string' && isValidUrl(Source)) {
214
+ return (
215
+ <Suspense fallback={<svg {...props} {...defaultProps} />}>
216
+ <UrlSvgLoader url={Source} {...props} {...defaultProps}/>
217
+ </Suspense>
218
+ );
219
+ }
220
+
221
+ const LazyIcon = typeof Source === 'string' && Source in lazyIcons
222
+ ? lazyIcons[Source as IconType]
223
+ : null;
224
+
225
+ const isRejectedUrl = typeof Source === 'string' &&
226
+ (Source.startsWith('http') || Source.startsWith('//') || Source.startsWith('/'));
227
+
103
228
  return (
104
229
  <Suspense fallback={<svg {...props} {...defaultProps} />}>
105
230
  {LazyIcon ? (
106
231
  <LazyIcon {...props} {...defaultProps} />
232
+ ) : isRejectedUrl ? (
233
+ <svg {...props} {...defaultProps} />
107
234
  ) : (
108
235
  // @ts-ignore
109
236
  <Source {...props} {...defaultProps} />