diff --git a/README.md b/README.md
index 5a55ce531..631c52f86 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Stripes Core
-Copyright (C) 2016-2018 The Open Library Foundation
+Copyright (C) 2016-2019 The Open Library Foundation
This software is distributed under the terms of the Apache License,
Version 2.0. See the file "[LICENSE](LICENSE)" for more information.
diff --git a/src/components/AppIcon/AppIcon.css b/src/components/AppIcon/AppIcon.css
new file mode 100644
index 000000000..bdab31575
--- /dev/null
+++ b/src/components/AppIcon/AppIcon.css
@@ -0,0 +1,78 @@
+/**
+ * AppIcon
+ */
+
+@import "@folio/stripes-components/lib/variables";
+
+/**
+ * Icon
+ */
+
+.appIcon {
+ display: inline-flex;
+ align-items: center;
+}
+
+/**
+ * Label
+ */
+
+.label {
+ margin: 0 0 0 0.35em;
+}
+
+[dir="rtl"] .label {
+ margin: 0 0.35em 0 0;
+}
+
+/**
+ * Icon
+ */
+.icon {
+ display: inline-block;
+ line-height: 1;
+ position: relative;
+ opacity: 1;
+ transition: none;
+ border-radius: 25%;
+ background-color: var(--color-icon) !important;
+
+ &::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.2);
+ border-radius: 25%;
+ }
+
+ & img {
+ width: 100%;
+ border-radius: 25%;
+ height: auto;
+ vertical-align: top;
+ }
+}
+
+/**
+ * Sizes
+ */
+.large .icon {
+ height: 48px;
+ min-width: 48px;
+ width: 48px;
+}
+
+.medium .icon {
+ width: 24px;
+ min-width: 24px;
+ height: 24px;
+}
+
+.small .icon {
+ width: 14px;
+ min-width: 14px;
+ height: 14px;
+}
diff --git a/src/components/AppIcon/AppIcon.js b/src/components/AppIcon/AppIcon.js
new file mode 100644
index 000000000..947c40518
--- /dev/null
+++ b/src/components/AppIcon/AppIcon.js
@@ -0,0 +1,140 @@
+/**
+ * App Icon
+ *
+ * Used to display an app's icon
+ * in various places across the application
+ */
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import { result } from 'lodash';
+import classNames from 'classnames';
+import { withStripes } from '../../StripesContext';
+import css from './AppIcon.css';
+
+const AppIcon = ({
+ iconAriaHidden,
+ size,
+ icon,
+ alt,
+ src,
+ style,
+ children,
+ className,
+ tag,
+ app,
+ iconKey,
+ stripes,
+}) => {
+ const getIcon = () => {
+ let appIconProps;
+
+ /**
+ * Icon from context
+ *
+ * We get the icons from the metadata which is passed down via context.
+ * The default app icon has an iconKey of "app".
+ *
+ * If no icon is found we display a placeholder.
+ *
+ */
+ const appIcon = result(stripes, `metadata.${app}.icons.${iconKey}`);
+ if (appIcon && appIcon.src) {
+ appIconProps = {
+ src: appIcon.src,
+ alt: appIcon.alt,
+ };
+
+ // Use PNGs (if available) for small app icons on non-retina screens
+ const isRetina = window.matchMedia(`
+ (-webkit-min-device-pixel-ratio: 2),
+ (min-device-pixel-ratio: 2),
+ (min-resolution: 192dpi)
+ `).matches;
+
+ // Ignoring next block in tests since it can't be tested consistently
+ // istanbul ignore next
+ if (!isRetina && size === 'small' && appIcon.low && appIcon.low.src) {
+ appIconProps.src = appIcon.low.src;
+ }
+ }
+
+ /* If we have an image passed as an object */
+ if (typeof icon === 'object') {
+ appIconProps = {
+ src: icon.src,
+ alt: icon.alt,
+ };
+ }
+
+ // No image props - return nothing and the placeholder will be active
+ if (!appIconProps) {
+ return null;
+ }
+
+ return (
+
+ );
+ };
+
+ /**
+ * Root CSS styles
+ */
+ const rootStyles = classNames(
+ /* Base app icon styling */
+ css.appIcon,
+ /* Icon size */
+ css[size],
+ /* Custom ClassName */
+ className,
+ );
+
+ /**
+ * Element - changeable by prop
+ */
+ const Element = tag;
+
+ /**
+ * Render
+ */
+ return (
+
+
+ {getIcon()}
+
+ { children && {children} }
+
+ );
+};
+
+AppIcon.propTypes = {
+ alt: PropTypes.string,
+ app: PropTypes.string,
+ children: PropTypes.node,
+ className: PropTypes.string,
+ icon: PropTypes.shape({
+ alt: PropTypes.string,
+ src: PropTypes.string.isRequired,
+ }),
+ iconAriaHidden: PropTypes.bool,
+ iconKey: PropTypes.string,
+ size: PropTypes.oneOf(['small', 'medium', 'large']),
+ src: PropTypes.string,
+ stripes: PropTypes.shape({
+ metadata: PropTypes.object,
+ }),
+ style: PropTypes.object,
+ tag: PropTypes.string,
+};
+
+AppIcon.defaultProps = {
+ iconKey: 'app',
+ size: 'medium',
+ tag: 'span',
+ iconAriaHidden: true,
+};
+
+export default withStripes(AppIcon);
diff --git a/src/components/AppIcon/index.js b/src/components/AppIcon/index.js
new file mode 100644
index 000000000..3d5865223
--- /dev/null
+++ b/src/components/AppIcon/index.js
@@ -0,0 +1 @@
+export { default } from './AppIcon';
diff --git a/src/components/AppIcon/readme.md b/src/components/AppIcon/readme.md
new file mode 100644
index 000000000..a2a7cff6a
--- /dev/null
+++ b/src/components/AppIcon/readme.md
@@ -0,0 +1,58 @@
+# AppIcon
+
+Displays an app's icon in various sizes.
+
+## Usage
+AppIcon supports different ways of loading icons.
+
+***1. Use context (recommended)***
+```js
+ import { AppIcon } from '@folio/stripes-core/src/components';
+
+ // Note: Make sure that the AppIcon has "stripes" in context as it relies on stripes.metadata.
+
+ ```
+ Optional: You can supply an iconKey if you need a specific icon within an app. If the specific icon isn't bundled with the app it will simply render a placeholder.
+```js
+
+```
+
+***2. Supply an object to the icon prop***
+```js
+ const icon = {
+ src: '/static/some-icon.png',
+ alt: 'My icon',
+ };
+
+
+ ```
+
+***3. Pass src and alt as props***
+```js
+
+ ```
+
+**Add a label to the icon by passing it as a child**
+ ```js
+
+ Users
+
+```
+
+## Props
+Name | Type | Description | default
+-- | -- | -- | --
+alt | string | Adds an 'alt'-attribute on the img-tag. | undefined
+app | string | The lowercased name of an app, e.g. "users" or "inventory". It will get the icon from metadata located in the stripes-object which should be available in React Context. Read more [here](https://github.com/folio-org/stripes-core/blob/master/doc/app-metadata.md#icons). | undefined
+children | node | Add content next to the icon - e.g. a label | undefined
+className | string | For adding custom class to component | undefined
+icon | object | Icon in form of an object. E.g. { src, alt } | undefined
+iconAriaHidden | bool | Applies aria-hidden to the icon element. Since ``'s mostly are rendered in proximity of a label or inside an element with a label (e.g. a button), we set aria-hidden to true per default to avoid screen readers reading the alt attribute of the icon img | true
+iconKey | string | A specific icon-key for apps with multiple icons. Defaults to "app" which corresponds to the required default app-icon of an app. | app
+size | string | Determines the size of the icon. (small, medium, large) | medium
+src | string | Manually set the 'src'-attribute on the img-tag | undefined
+style | object | For adding custom style to component | undefined
+tag | string | Changes the rendered root HTML-element | span
diff --git a/src/components/MainNav/AppList/List.js b/src/components/MainNav/AppList/List.js
index 4607ce3a8..3832b9790 100644
--- a/src/components/MainNav/AppList/List.js
+++ b/src/components/MainNav/AppList/List.js
@@ -6,10 +6,11 @@ import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
-import AppIcon from '@folio/stripes-components/lib/AppIcon';
import NavListItem from '@folio/stripes-components/lib/NavListItem';
import NavListItemStyles from '@folio/stripes-components/lib/NavListItem/NavListItem.css';
+import AppIcon from '../../AppIcon';
+
import css from './AppList.css';
const List = React.forwardRef(({ apps, onItemClick }, ref) => {
diff --git a/src/components/MainNav/NavButton/NavButton.js b/src/components/MainNav/NavButton/NavButton.js
index 428721a13..3da83050d 100644
--- a/src/components/MainNav/NavButton/NavButton.js
+++ b/src/components/MainNav/NavButton/NavButton.js
@@ -2,8 +2,10 @@ import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Link from 'react-router-dom/Link';
-import AppIcon from '@folio/stripes-components/lib/AppIcon';
import Badge from '@folio/stripes-components/lib/Badge';
+
+import AppIcon from '../../AppIcon';
+
import css from './NavButton.css';
const propTypes = {
diff --git a/src/components/index.js b/src/components/index.js
index 877999978..8dd8cb201 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -1,4 +1,5 @@
export { default as About } from './About';
+export { default as AppIcon } from './AppIcon';
export { default as AuthErrorsContainer } from './AuthErrorsContainer';
export { default as CreateResetPassword } from './CreateResetPassword';
export { default as HandlerManager } from './HandlerManager';
diff --git a/test/bigtest/helpers/Harness.js b/test/bigtest/helpers/Harness.js
new file mode 100644
index 000000000..98f12d84f
--- /dev/null
+++ b/test/bigtest/helpers/Harness.js
@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { IntlProvider } from 'react-intl';
+import { reducer as formReducer } from 'redux-form';
+import { Provider } from 'react-redux';
+import { createStore, combineReducers } from 'redux';
+
+import translations from '@folio/stripes-components/translations/stripes-components/en';
+
+const reducers = {
+ form: formReducer,
+};
+
+const reducer = combineReducers(reducers);
+
+const store = createStore(reducer);
+
+// mimics the StripesTranslationPlugin in @folio/stripes-core
+function prefixKeys(obj) {
+ const res = {};
+ for (const key of Object.keys(obj)) {
+ res[`stripes-components.${key}`] = obj[key];
+ }
+ return res;
+}
+
+class Harness extends React.Component {
+ render() {
+ return (
+
+
+ {this.props.children}
+
+
+ );
+ }
+}
+
+Harness.propTypes = {
+ children: PropTypes.node,
+};
+
+export default Harness;
diff --git a/test/bigtest/helpers/render-helpers.js b/test/bigtest/helpers/render-helpers.js
new file mode 100644
index 000000000..e949f13fd
--- /dev/null
+++ b/test/bigtest/helpers/render-helpers.js
@@ -0,0 +1,39 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import Harness from './Harness';
+
+import '@folio/stripes-components/lib/global.css';
+
+function getCleanTestingRoot() {
+ let $root = document.getElementById('root');
+
+ // if a root exists, unmount anything inside and remove it
+ if ($root) {
+ ReactDOM.unmountComponentAtNode($root);
+ $root.parentNode.removeChild($root);
+ }
+
+ // create a brand new root element
+ $root = document.createElement('div');
+ $root.id = 'root';
+
+ document.body.appendChild($root);
+
+ return $root;
+}
+
+export function mount(component) {
+ return new Promise(resolve => {
+ ReactDOM.render(component, getCleanTestingRoot(), resolve);
+ });
+}
+
+export function mountWithContext(component) {
+ return new Promise(resolve => {
+ ReactDOM.render({component}, getCleanTestingRoot(), resolve);
+ });
+}
+
+export function selectorFromClassnameString(str) {
+ return str.replace(/\s/, '.');
+}
diff --git a/test/bigtest/interactors/AppIcon.js b/test/bigtest/interactors/AppIcon.js
new file mode 100644
index 000000000..130bb2a12
--- /dev/null
+++ b/test/bigtest/interactors/AppIcon.js
@@ -0,0 +1,19 @@
+/**
+ * AppIcon interactor
+ */
+
+import { interactor, isPresent, find, text, property, attribute } from '@bigtest/interactor';
+import { selectorFromClassnameString } from '../helpers/render-helpers';
+import css from '../../../src/components/AppIcon/AppIcon.css';
+
+const iconClassSelector = selectorFromClassnameString(`.${css.appIcon}`);
+
+export default interactor(class AppIconInteractor {
+ static defaultScope = iconClassSelector;
+
+ hasImg = isPresent('img');
+ img = find('img');
+ label = text();
+ tag = property('tagName');
+ className = attribute('class');
+});
diff --git a/test/bigtest/tests/AppIcon/appIcon-test.js b/test/bigtest/tests/AppIcon/appIcon-test.js
new file mode 100644
index 000000000..c0fe5dba0
--- /dev/null
+++ b/test/bigtest/tests/AppIcon/appIcon-test.js
@@ -0,0 +1,146 @@
+/**
+ * AppIcon tests
+ */
+
+import React from 'react';
+
+import { beforeEach, it, describe } from '@bigtest/mocha';
+import { expect } from 'chai';
+
+import { mount } from '../../helpers/render-helpers';
+
+import AppIcon from '../../../../src/components/AppIcon';
+import AppIconInteractor from '../../interactors/AppIcon';
+import png from './users-app-icon.png';
+import svg from './users-app-icon.svg';
+
+describe('AppIcon', () => {
+ const appIcon = new AppIconInteractor();
+ const alt = 'My alt';
+ const label = 'My label';
+ const tag = 'div';
+ const className = 'My className';
+
+ const iconObject = {
+ src: png,
+ alt,
+ };
+
+ const iconSizes = {
+ small: 14,
+ medium: 24,
+ large: 48,
+ };
+
+ // This is a mock of the Stripes context that's available in an Folio app
+ const stripesMock = {
+ metadata: {
+ users: {
+ icons: {
+ app: {
+ alt: 'Create, view and manage users',
+ src: svg,
+ high: {
+ src: svg,
+ },
+ low: {
+ src: png,
+ }
+ }
+ }
+ }
+ }
+ };
+
+ describe('Rendering an AppIcon using Stripes-context', () => {
+ beforeEach(async () => {
+ await mount(
+
+ );
+ });
+
+ it('Should render an ', () => {
+ expect(appIcon.hasImg).to.be.true;
+ });
+
+ it('Should render an img with an alt-attribute', () => {
+ expect(appIcon.img.alt).to.equal(stripesMock.metadata.users.icons.app.alt);
+ });
+ });
+
+ describe('Rendering an AppIcon using an icon-object', () => {
+ beforeEach(async () => {
+ await mount(
+
+ );
+ });
+
+ it('Should render an ', () => {
+ expect(appIcon.hasImg).to.be.true;
+ });
+
+ it('Should render an img with an alt-attribute', () => {
+ expect(appIcon.img.alt).to.equal(alt);
+ });
+
+ it(`Should render with a className of "${className}"`, () => {
+ expect(appIcon.className).to.include(className);
+ });
+ });
+
+ describe('Passing a string using the children-prop', () => {
+ beforeEach(async () => {
+ await mount(
+
+ {label}
+
+ );
+ });
+
+ it('Should render an AppIcon with a label', () => {
+ expect(appIcon.label).to.equal(label);
+ });
+ });
+
+ describe('Passing a string to the tag-prop', () => {
+ beforeEach(async () => {
+ await mount(
+
+ );
+ });
+
+ it(`Should render an AppIcon with a HTML tag of "${tag}"`, () => {
+ expect(appIcon.tag.toLowerCase()).to.equal(tag);
+ });
+ });
+
+
+ Object.keys(iconSizes).forEach(size => {
+ describe(`Passing a size of "${size}"`, () => {
+ beforeEach(async () => {
+ await mount(
+
+ );
+ });
+ it(`Should render an icon with a width of ${iconSizes[size]}px`, () => {
+ expect(appIcon.img.offsetWidth).to.equal(iconSizes[size]);
+ });
+ it(`Should render an icon with a height of ${iconSizes[size]}px`, () => {
+ expect(appIcon.img.offsetHeight).to.equal(iconSizes[size]);
+ });
+ });
+ });
+});
diff --git a/test/bigtest/tests/AppIcon/users-app-icon.png b/test/bigtest/tests/AppIcon/users-app-icon.png
new file mode 100644
index 000000000..a1f8ffcd2
Binary files /dev/null and b/test/bigtest/tests/AppIcon/users-app-icon.png differ
diff --git a/test/bigtest/tests/AppIcon/users-app-icon.svg b/test/bigtest/tests/AppIcon/users-app-icon.svg
new file mode 100644
index 000000000..57dfc8095
--- /dev/null
+++ b/test/bigtest/tests/AppIcon/users-app-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file