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 ( + {typeof + ); + }; + + /** + * 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