Skip to content

Commit

Permalink
Add untrusted prop to render svg strings in an img tag
Browse files Browse the repository at this point in the history
  • Loading branch information
theodoretan committed Feb 15, 2019
1 parent 09143c7 commit d6faf7b
Show file tree
Hide file tree
Showing 7 changed files with 168 additions and 57 deletions.
1 change: 1 addition & 0 deletions UNRELEASED.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Use [the changelog guidelines](https://git.io/polaris-changelog-guidelines) to f
- Updates `TopBar.UserMenu` interaction states styling ([#1006](https://github.com/Shopify/polaris-react/pull/1006))
- Added `download` prop to `Button` and `UnstyledLink` components that enables setting the download attribute ([#1027](https://github.com/Shopify/polaris-react/pull/1027))
- Extract months and week names into translation files ([#1005](https://github.com/Shopify/polaris-react/pull/1005))
- Added `untrusted` prop to `Icon` to render SVG strings in an img tag ([#926](https://github.com/Shopify/polaris-react/pull/926))

### Bug fixes

Expand Down
87 changes: 49 additions & 38 deletions src/components/Icon/Icon.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@ $stacking-order: (
icon: 2,
);

@mixin color-icon($value, $hue: base) {
svg {
fill: color($value, $hue);
}

img {
filter: filter($value, $hue);
}
}

.Icon {
display: block;
height: rem(20px);
Expand Down Expand Up @@ -35,191 +45,192 @@ $stacking-order: (
}

.colorWhite {
fill: color('white');
@include color-icon('white');
color: transparent;
}

.colorBlack {
fill: color('black');
@include color-icon('black');
}

.colorSkyLighter {
fill: color('sky', 'lighter');
@include color-icon('sky', 'lighter');
}

.colorSkyLight {
fill: color('sky', 'light');
@include color-icon('sky', 'light');
}

.colorSky {
fill: color('sky');
@include color-icon('sky');
}

.colorSkyDark {
fill: color('sky', 'dark');
@include color-icon('sky', 'dark');
}

.colorInkLightest {
fill: color('ink', 'lightest');
@include color-icon('ink', 'lightest');
}

.colorInkLighter {
fill: color('ink', 'lighter');
@include color-icon('ink', 'lighter');

&::after {
background-color: color('sky');
}
}

.colorInkLight {
fill: color('ink', 'light');
@include color-icon('ink', 'light');
}

.colorInk {
fill: color('ink');
@include color-icon('ink');

&::after {
background-color: color('sky');
}
}

.colorBlueLighter {
fill: color('blue', 'lighter');
@include color-icon('blue', 'lighter');
}

.colorBlueLight {
fill: color('blue', 'light');
@include color-icon('blue', 'light');
}

.colorBlue {
fill: color('blue');
@include color-icon('blue');
}

.colorBlueDark {
fill: color('blue', 'dark');
@include color-icon('blue', 'dark');

&::after {
background-color: color('blue', 'light');
}
}

.colorBlueDarker {
fill: color('blue', 'darker');
@include color-icon('blue', 'darker');
}

.colorIndigoLighter {
fill: color('indigo', 'lighter');
@include color-icon('indigo', 'lighter');
}

.colorIndigoLight {
fill: color('indigo', 'light');
@include color-icon('indigo', 'light');
}

.colorIndigo {
fill: color('indigo');
@include color-icon('indigo');
}

.colorIndigoDark {
fill: color('indigo', 'dark');
@include color-icon('indigo', 'dark');
}

.colorIndigoDarker {
fill: color('indigo', 'darker');
@include color-icon('indigo', 'darker');
}

.colorTealLighter {
fill: color('teal', 'lighter');
@include color-icon('teal', 'lighter');
}

.colorTealLight {
fill: color('teal', 'light');
@include color-icon('teal', 'light');
}

.colorTeal {
fill: color('teal');
@include color-icon('teal');

&::after {
background-color: color('white');
}
}

.colorTealDark {
fill: color('teal', 'dark');
@include color-icon('teal', 'dark');

&::after {
background-color: color('teal', 'light');
}
}

.colorTealDarker {
fill: color('teal', 'darker');
@include color-icon('teal', 'darker');
}

.colorGreenLighter {
fill: color('green', 'lighter');
@include color-icon('green', 'lighter');
}

.colorGreen {
fill: color('green');
@include color-icon('green');

&::after {
background-color: color('green', 'lighter');
}
}

.colorGreenDark {
fill: color('green', 'dark');
@include color-icon('green', 'dark');

&::after {
background-color: color('green', 'light');
}
}

.colorYellowLighter {
fill: color('yellow', 'lighter');
@include color-icon('yellow', 'lighter');
}

.colorYellow {
fill: color('yellow');
@include color-icon('yellow');
}

.colorYellowDark {
fill: color('yellow', 'dark');
@include color-icon('yellow', 'dark');

&::after {
background-color: color('yellow', 'light');
}
}

.colorOrange {
fill: color('orange');
@include color-icon('orange');
}

.colorOrangeDark {
fill: color('orange', 'dark');
@include color-icon('orange', 'dark');
}

.colorRedLighter {
fill: color('red', 'lighter');
@include color-icon('red', 'lighter');
}

.colorRed {
fill: color('red');
@include color-icon('red');
}

.colorRedDark {
fill: color('red', 'dark');
@include color-icon('red', 'dark');

&::after {
background-color: color('red', 'light');
}
}

.colorPurple {
fill: color('purple');
@include color-icon('purple');
}

.Svg {
.Svg,
.Img {
position: relative;
z-index: z-index(icon, $stacking-order);
display: block;
Expand Down
66 changes: 47 additions & 19 deletions src/components/Icon/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,14 @@ const COLORS_WITH_BACKDROPS = [

export type BundledIcon = keyof typeof BUNDLED_ICONS;

export type UntrustedSVG = string;

export type IconSource =
| React.ReactNode
| SVGSource
| 'placeholder'
| BundledIcon;
| BundledIcon
| UntrustedSVG;
export interface Props {
/** The SVG contents to display in the icon. Icons should be in a 20 X 20 pixel viewbox */
source: IconSource;
Expand All @@ -179,6 +182,8 @@ export interface Props {
backdrop?: boolean;
/** Descriptive text to be read to screenreaders */
accessibilityLabel?: string;
/** Render the icon in an img tag instead of an svg. Prevents XSS */
untrusted?: boolean;
}

export type CombinedProps = Props & WithAppProviderProps;
Expand All @@ -188,6 +193,7 @@ function Icon({
color,
backdrop,
accessibilityLabel,
untrusted = false,
polaris: {intl},
}: CombinedProps) {
if (color && backdrop && COLORS_WITH_BACKDROPS.indexOf(color) < 0) {
Expand All @@ -212,22 +218,20 @@ function Icon({
contentMarkup = <div className={styles.Placeholder} />;
} else if (React.isValidElement(source)) {
contentMarkup = source;
} else {
const iconSource =
typeof source === 'string' && isBundledIcon(source)
? BUNDLED_ICONS[source]
: source;
contentMarkup = iconSource &&
iconSource.viewBox &&
iconSource.body && (
<svg
className={styles.Svg}
viewBox={iconSource.viewBox}
dangerouslySetInnerHTML={{__html: iconSource.body}}
focusable="false"
aria-hidden="true"
/>
);
} else if (isBundledIcon(source)) {
const iconSource = BUNDLED_ICONS[source] as SVGSource;
contentMarkup = renderSVG(iconSource);
} else if (untrusted && isUntrustedSVG(source)) {
contentMarkup = (
<img
className={styles.Img}
src={`data:image/svg+xml;base64,${btoa(source)}`}
alt=""
aria-hidden="true"
/>
);
} else if (isSVGSource(source)) {
contentMarkup = renderSVG(source);
}

return (
Expand All @@ -237,8 +241,32 @@ function Icon({
);
}

function isBundledIcon(key: string | BundledIcon): key is BundledIcon {
return Object.keys(BUNDLED_ICONS).includes(key);
function renderSVG(iconSource: SVGSource) {
return (
<svg
className={styles.Svg}
viewBox={iconSource.viewBox}
dangerouslySetInnerHTML={{__html: iconSource.body}}
focusable="false"
aria-hidden="true"
/>
);
}

function isBundledIcon(key: IconSource): key is BundledIcon {
return typeof key === 'string' && Object.keys(BUNDLED_ICONS).includes(key);
}

function isSVGSource(source: IconSource): source is SVGSource {
return (
source != null &&
source.hasOwnProperty('viewBox') &&
source.hasOwnProperty('body')
);
}

function isUntrustedSVG(source: IconSource): source is UntrustedSVG {
return typeof source === 'string';
}

export default withAppProvider<Props>()(Icon);
11 changes: 11 additions & 0 deletions src/components/Icon/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,14 @@ Use to visually communicate core parts of the product and available actions.
```jsx
<Icon source="circlePlus" />
```

### User provided icon

Use to mark an icon as untrusted to render it in an img tag.

```jsx
<Icon
source="<svg><path d='M10.707 17.707l5-5a.999.999 0 1 0-1.414-1.414L11 14.586V3a1 1 0 1 0-2 0v11.586l-3.293-3.293a.999.999 0 1 0-1.414 1.414l5 5a.999.999 0 0 0 1.414 0' /></svg>"
untrusted
/>
```
15 changes: 15 additions & 0 deletions src/components/Icon/tests/Icon.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,20 @@ describe('<Icon />', () => {
const element = shallowWithAppProvider(<Icon source={component} />);
expect(element.find(Button)).toHaveLength(1);
});

it('renders an img when source is given an untrusted SVG', () => {
const svg =
"<svg><path d='M17 9h-6V3a1 1 0 1 0-2 0v6H3a1 1 0 1 0 0 2h6v6a1 1 0 1 0 2 0v-6h6a1 1 0 1 0 0-2' fill-rule='evenodd'/></svg>";
const element = shallowWithAppProvider(<Icon source={svg} untrusted />);
expect(element.find('img')).toHaveLength(1);
});

it('renders nothing when source is given an svg string but untrusted is not true', () => {
const svg =
"<svg><path d='M17 9h-6V3a1 1 0 1 0-2 0v6H3a1 1 0 1 0 0 2h6v6a1 1 0 1 0 2 0v-6h6a1 1 0 1 0 0-2' fill-rule='evenodd'/></svg>";
const element = shallowWithAppProvider(<Icon source={svg} />);
expect(element.find('img')).toHaveLength(0);
expect(element.find('svg')).toHaveLength(0);
});
});
});
Loading

0 comments on commit d6faf7b

Please sign in to comment.