@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
|
@@ -55,8 +55,8 @@
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/* Primary */
|
|
58
|
-
|
|
59
|
-
a.dropin-button
|
|
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
|
-
|
|
76
|
-
a.dropin-button
|
|
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
|
-
|
|
86
|
-
a.dropin-button
|
|
85
|
+
.dropin-button--primary:hover,
|
|
86
|
+
a.dropin-button--primary:hover,
|
|
87
87
|
.dropin-iconButton--primary:hover,
|
|
88
|
-
|
|
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
|
-
|
|
94
|
+
.dropin-button--primary:focus,
|
|
95
95
|
.dropin-iconButton--primary:focus {
|
|
96
96
|
background-color: var(--color-brand-500);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
|
|
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<
|
|
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<
|
|
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
|
-
|
|
62
|
-
source
|
|
63
|
-
|
|
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
|
|
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
|
-
|
|
71
|
-
|
|
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} />
|