feat(icon): add icon lazy loading system
This commit is contained in:
parent
da2729e323
commit
f887f973f0
18 changed files with 556 additions and 331 deletions
|
|
@ -1 +1,2 @@
|
||||||
export { LionIcon } from './src/LionIcon.js';
|
export { LionIcon } from './src/LionIcon.js';
|
||||||
|
export { icons } from './src/icons.js';
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@open-wc/demoing-storybook": "^1.10.4",
|
"@open-wc/demoing-storybook": "^1.10.4",
|
||||||
"@open-wc/testing": "^2.5.0"
|
"@open-wc/testing": "^2.5.0",
|
||||||
|
"sinon": "^7.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
63
packages/icon/src/IconManager.js
Normal file
63
packages/icon/src/IconManager.js
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { LionSingleton } from '@lion/core';
|
||||||
|
|
||||||
|
export class IconManager extends LionSingleton {
|
||||||
|
constructor(params = {}) {
|
||||||
|
super(params);
|
||||||
|
|
||||||
|
this.__iconResolvers = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an icon resolver for the given namespace. An icon resolver is a
|
||||||
|
* function which takes an icon set and an icon name and returns an svg
|
||||||
|
* icon as a TemplateResult. This function can be sync or async.
|
||||||
|
*
|
||||||
|
* @param {string} namespace
|
||||||
|
* @param {(iconset: string, icon: string) => TemplateResult | Promise<TemplateResult>} iconResolver
|
||||||
|
*/
|
||||||
|
addIconResolver(namespace, iconResolver) {
|
||||||
|
if (this.__iconResolvers.has(namespace)) {
|
||||||
|
throw new Error(`An icon resolver has already been registered for namespace: ${namespace}`);
|
||||||
|
}
|
||||||
|
this.__iconResolvers.set(namespace, iconResolver);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes an icon resolver for a namespace.
|
||||||
|
* @param {string} namespace
|
||||||
|
*/
|
||||||
|
removeIconResolver(namespace) {
|
||||||
|
this.__iconResolvers.delete(namespace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves icon for the given parameters. Returns the icon as a svg string.
|
||||||
|
*
|
||||||
|
* @param {string} namespace
|
||||||
|
* @param {string} iconset
|
||||||
|
* @param {string} icon
|
||||||
|
* @returns {Promise<TemplateResult>}
|
||||||
|
*/
|
||||||
|
resolveIcon(namespace, iconset, icon) {
|
||||||
|
const resolver = this.__iconResolvers.get(namespace);
|
||||||
|
if (resolver) {
|
||||||
|
return resolver(iconset, icon);
|
||||||
|
}
|
||||||
|
throw new Error(`Could not find any icon resolver for namespace ${namespace}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves icon for the given icon id. Returns the icon as a svg string.
|
||||||
|
*
|
||||||
|
* @param {string} iconId
|
||||||
|
* @returns {Promise<TemplateResult>}
|
||||||
|
*/
|
||||||
|
resolveIconForId(iconId) {
|
||||||
|
const splitIconId = iconId.split(':');
|
||||||
|
if (splitIconId.length !== 3) {
|
||||||
|
throw new Error(`Incorrect iconId: ${iconId}. Format: <namespace>:<iconset>:<icon>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.resolveIcon(...splitIconId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,28 +1,61 @@
|
||||||
import { html, nothing, TemplateResult, css, render, LitElement } from '@lion/core';
|
import { html, nothing, TemplateResult, css, render, LitElement } from '@lion/core';
|
||||||
|
import { icons } from './icons.js';
|
||||||
|
|
||||||
const isPromise = action => typeof action === 'object' && Promise.resolve(action) === action;
|
function unwrapSvg(wrappedSvgObject) {
|
||||||
|
const svgObject =
|
||||||
|
wrappedSvgObject && wrappedSvgObject.default ? wrappedSvgObject.default : wrappedSvgObject;
|
||||||
|
return typeof svgObject === 'function' ? svgObject(html) : svgObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSvg(svg) {
|
||||||
|
if (!(svg === nothing || svg instanceof TemplateResult)) {
|
||||||
|
throw new Error(
|
||||||
|
'icon accepts only lit-html templates or functions like "tag => tag`<svg>...</svg>`"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom element for rendering SVG icons
|
* Custom element for rendering SVG icons
|
||||||
* @polymerElement
|
|
||||||
*/
|
*/
|
||||||
export class LionIcon extends LitElement {
|
export class LionIcon extends LitElement {
|
||||||
static get properties() {
|
static get properties() {
|
||||||
return {
|
return {
|
||||||
// svg is a property to ensure the setter is called if the property is set before upgrading
|
/**
|
||||||
|
* @desc When icons are not loaded as part of an iconset defined on iconManager,
|
||||||
|
* it's possible to directly load an svg.
|
||||||
|
* @type {TemplateResult|function}
|
||||||
|
*/
|
||||||
svg: {
|
svg: {
|
||||||
type: Object,
|
type: Object,
|
||||||
},
|
},
|
||||||
role: {
|
/**
|
||||||
type: String,
|
* @desc The iconId allows to access icons that are registered to the IconManager
|
||||||
attribute: 'role',
|
* For instance, "lion:space:alienSpaceship"
|
||||||
reflect: true,
|
* @type {string}
|
||||||
},
|
*/
|
||||||
ariaLabel: {
|
ariaLabel: {
|
||||||
type: String,
|
type: String,
|
||||||
attribute: 'aria-label',
|
attribute: 'aria-label',
|
||||||
reflect: true,
|
reflect: true,
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* @desc The iconId allows to access icons that are registered to the IconManager
|
||||||
|
* For instance, "lion:space:alienSpaceship"
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
iconId: {
|
||||||
|
type: String,
|
||||||
|
attribute: 'icon-id',
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
role: {
|
||||||
|
type: String,
|
||||||
|
attribute: 'role',
|
||||||
|
reflect: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,6 +96,10 @@ export class LionIcon extends LitElement {
|
||||||
if (changedProperties.has('ariaLabel')) {
|
if (changedProperties.has('ariaLabel')) {
|
||||||
this._onLabelChanged(changedProperties);
|
this._onLabelChanged(changedProperties);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (changedProperties.has('iconId')) {
|
||||||
|
this._onIconIdChanged(changedProperties.get('iconId'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|
@ -85,16 +122,8 @@ export class LionIcon extends LitElement {
|
||||||
this.__svg = svg;
|
this.__svg = svg;
|
||||||
if (svg === undefined || svg === null) {
|
if (svg === undefined || svg === null) {
|
||||||
this._renderSvg(nothing);
|
this._renderSvg(nothing);
|
||||||
} else if (isPromise(svg)) {
|
|
||||||
this._renderSvg(nothing); // show nothing before resolved
|
|
||||||
svg.then(resolvedSvg => {
|
|
||||||
// render only if it is still the same and was not replaced after loading started
|
|
||||||
if (svg === this.__svg) {
|
|
||||||
this._renderSvg(this.constructor.__unwrapSvg(resolvedSvg));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
this._renderSvg(this.constructor.__unwrapSvg(svg));
|
this._renderSvg(unwrapSvg(svg));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,21 +141,24 @@ export class LionIcon extends LitElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
_renderSvg(svgObject) {
|
_renderSvg(svgObject) {
|
||||||
this.constructor.__validateSvg(svgObject);
|
validateSvg(svgObject);
|
||||||
render(svgObject, this);
|
render(svgObject, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
static __unwrapSvg(wrappedSvgObject) {
|
async _onIconIdChanged(prevIconId) {
|
||||||
const svgObject =
|
if (!this.iconId) {
|
||||||
wrappedSvgObject && wrappedSvgObject.default ? wrappedSvgObject.default : wrappedSvgObject;
|
// clear if switching from iconId to no iconId
|
||||||
return typeof svgObject === 'function' ? svgObject(html) : svgObject;
|
if (prevIconId) {
|
||||||
}
|
this.svg = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const iconIdBeforeResolve = this.iconId;
|
||||||
|
const svg = await icons.resolveIconForId(iconIdBeforeResolve);
|
||||||
|
|
||||||
static __validateSvg(svg) {
|
// update SVG if it did not change in the meantime to avoid race conditions
|
||||||
if (!(svg === nothing || svg instanceof TemplateResult)) {
|
if (this.iconId === iconIdBeforeResolve) {
|
||||||
throw new Error(
|
this.svg = svg;
|
||||||
'icon accepts only lit-html templates or functions like "tag => tag`<svg>...</svg>`"',
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
packages/icon/src/icons.js
Normal file
8
packages/icon/src/icons.js
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { IconManager } from './IconManager.js';
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-mutable-exports
|
||||||
|
export let icons = IconManager.getInstance();
|
||||||
|
|
||||||
|
export function setIcons(newIcons) {
|
||||||
|
icons = newIcons;
|
||||||
|
}
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import { html } from '@open-wc/demoing-storybook';
|
|
||||||
import '../lion-icon.js';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
title: 'Icons/System/_internal',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dynamicIcon = () => html`
|
|
||||||
<lion-icon
|
|
||||||
.svg=${import('./icons/bugs/bug05.svg.js')}
|
|
||||||
aria-label="Skinny dung beatle"
|
|
||||||
></lion-icon>
|
|
||||||
`;
|
|
||||||
14
packages/icon/stories/icon-resolvers.js
Normal file
14
packages/icon/stories/icon-resolvers.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { icons } from '../index.js';
|
||||||
|
|
||||||
|
icons.addIconResolver('lion', (iconset, name) => {
|
||||||
|
switch (iconset) {
|
||||||
|
case 'bugs':
|
||||||
|
return import('./icons/iconset-bugs.js').then(module => module[name]);
|
||||||
|
case 'space':
|
||||||
|
return import('./icons/iconset-space.js').then(module => module[name]);
|
||||||
|
case 'misc':
|
||||||
|
return import('./icons/iconset-misc.js').then(module => module[name]);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown iconset ${iconset}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
104
packages/icon/stories/icon.stories.mdx
Normal file
104
packages/icon/stories/icon.stories.mdx
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { Story, Meta, html } from '@open-wc/demoing-storybook';
|
||||||
|
import { bug24 } from './icons/iconset-bugs.js';
|
||||||
|
import '../lion-icon.js';
|
||||||
|
import './icon-resolvers.js';
|
||||||
|
|
||||||
|
<Meta title="Icons/Icon" />
|
||||||
|
|
||||||
|
# Icon
|
||||||
|
|
||||||
|
A web component for displaying icons.
|
||||||
|
|
||||||
|
<Story name="Default">
|
||||||
|
{html`
|
||||||
|
<style>
|
||||||
|
lion-icon {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<lion-icon icon-id="lion:space:alienSpaceship"></lion-icon>
|
||||||
|
`}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
```html
|
||||||
|
<lion-icon icon-id="lion:space:alienSpaceship"></lion-icon>
|
||||||
|
```
|
||||||
|
|
||||||
|
## How to use
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm i --save @lion/icon
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import '@lion/icon/lion-icon.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Displaying icons
|
||||||
|
|
||||||
|
Icons are displayed using icon sets. These are collections of icons, lazily loaded on demand for performance.
|
||||||
|
See the system documentation to learn more about icon sets.
|
||||||
|
|
||||||
|
<Story name="Iconset">
|
||||||
|
{html`
|
||||||
|
<lion-icon icon-id="lion:space:alienSpaceship"></lion-icon>
|
||||||
|
`}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
```html
|
||||||
|
<lion-icon icon-id="lion:space:alienSpaceship"></lion-icon>
|
||||||
|
```
|
||||||
|
|
||||||
|
If for some reason you don't want to lazy load icons, you can still import and use them
|
||||||
|
synchronously.
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
|
||||||
|
It is recommended to add an `aria-label` to provide information to visually impaired users:
|
||||||
|
|
||||||
|
A `lion-icon` without an `aria-label` attribute will be automatically given an `aria-hidden` attribute.
|
||||||
|
|
||||||
|
<Story name="Accessible label">
|
||||||
|
{html`
|
||||||
|
<lion-icon icon-id="lion:misc:arrowLeft" aria-label="Pointing left"></lion-icon>
|
||||||
|
`}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
```html
|
||||||
|
<lion-icon icon-id="lion:misc:arrowLeft" aria-label="Pointing left"></lion-icon>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
By default, a `lion-icon` will be `1em` × `1em` (the current line-height).
|
||||||
|
|
||||||
|
`lion-icon` uses SVGs and may be styled with CSS, including using CSS properties such as `fill` and `stroke`:
|
||||||
|
|
||||||
|
<Story name="Styling">
|
||||||
|
{html`
|
||||||
|
<style>
|
||||||
|
lion-icon.custom {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
fill: blue;
|
||||||
|
stroke: red;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<lion-icon icon-id="lion:bugs:bug02" aria-label="Pointing left"></lion-icon>
|
||||||
|
`}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
```html
|
||||||
|
<style>
|
||||||
|
lion-icon {
|
||||||
|
fill: blue;
|
||||||
|
stroke: lightsteelblue;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<lion-icon icon-id="lion:bugs:bug02" aria-label="Pointing left"></lion-icon>
|
||||||
|
```
|
||||||
|
|
||||||
|
See <a href="https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/SVG_and_CSS" target="_blank">SVG and CSS</a> on MDN web docs for more information.
|
||||||
3
packages/icon/stories/icons/iconset-misc.js
Normal file
3
packages/icon/stories/icons/iconset-misc.js
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import arrowLeft from './misc/arrowLeft.svg.js';
|
||||||
|
|
||||||
|
export { arrowLeft };
|
||||||
31
packages/icon/stories/icons/iconset-space.js
Executable file
31
packages/icon/stories/icons/iconset-space.js
Executable file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import alienSpaceship from './space/aliens-spaceship.svg.js';
|
||||||
|
import meteor from './space/meteor.svg.js';
|
||||||
|
import moonFlag from './space/moon-flag.svg.js';
|
||||||
|
import moon from './space/moon.svg.js';
|
||||||
|
import night from './space/night.svg.js';
|
||||||
|
import orbit from './space/orbit.svg.js';
|
||||||
|
import planet from './space/planet.svg.js';
|
||||||
|
import robot from './space/robot.svg.js';
|
||||||
|
import rocket from './space/rocket.svg.js';
|
||||||
|
import satellite from './space/satellite.svg.js';
|
||||||
|
import signal from './space/signal.svg.js';
|
||||||
|
import spaceHelmet from './space/space-helmet.svg.js';
|
||||||
|
import sun from './space/sun.svg.js';
|
||||||
|
import telescope from './space/telescope.svg.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
alienSpaceship,
|
||||||
|
meteor,
|
||||||
|
moonFlag,
|
||||||
|
moon,
|
||||||
|
night,
|
||||||
|
orbit,
|
||||||
|
planet,
|
||||||
|
robot,
|
||||||
|
rocket,
|
||||||
|
satellite,
|
||||||
|
signal,
|
||||||
|
spaceHelmet,
|
||||||
|
sun,
|
||||||
|
telescope,
|
||||||
|
};
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
import { Story, Meta, html } from '@open-wc/demoing-storybook';
|
|
||||||
import * as bugs from './icons/bugs-collection.js';
|
|
||||||
import arrowLeftSvg from './icons/arrowLeft.svg.js';
|
|
||||||
import '../lion-icon.js';
|
|
||||||
|
|
||||||
<Meta title="Icons/Icon" />
|
|
||||||
|
|
||||||
# Icon
|
|
||||||
|
|
||||||
A web component for displaying icons.
|
|
||||||
|
|
||||||
<Story name="Default">
|
|
||||||
{html`
|
|
||||||
<style>
|
|
||||||
lion-icon {
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<lion-icon .svg="${bugs.bug01}"></lion-icon>
|
|
||||||
`}
|
|
||||||
</Story>
|
|
||||||
|
|
||||||
```html
|
|
||||||
<lion-icon .svg="${bug01}"></lion-icon>
|
|
||||||
```
|
|
||||||
|
|
||||||
## How to use
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm i --save @lion/icon
|
|
||||||
```
|
|
||||||
|
|
||||||
```js
|
|
||||||
import '@lion/icon/lion-icon.js';
|
|
||||||
```
|
|
||||||
|
|
||||||
## Icon format
|
|
||||||
|
|
||||||
Icon file is an ES module with an extension `.svg.js` which exports a function like this:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// bug.svg.js
|
|
||||||
export default tag => tag`
|
|
||||||
<svg focusable="false" ...>...</svg>
|
|
||||||
`;
|
|
||||||
```
|
|
||||||
|
|
||||||
Make sure you have `focusable="false"` in the icon file to prevent bugs in IE/Edge when the icon appears in tab-order.
|
|
||||||
|
|
||||||
### Accessibility
|
|
||||||
|
|
||||||
It is recommended to add an `aria-label` to provide information to visually impaired users:
|
|
||||||
|
|
||||||
A `lion-icon` without an `aria-label` attribute will be automatically be given an `aria-hidden` attribute.
|
|
||||||
|
|
||||||
<Story name="Accessible label">
|
|
||||||
{html`
|
|
||||||
<lion-icon .svg="${arrowLeftSvg}" aria-label="Pointing left"></lion-icon>
|
|
||||||
`}
|
|
||||||
</Story>
|
|
||||||
|
|
||||||
```html
|
|
||||||
<lion-icon .svg="${arrowLeftSvg}" aria-label="Pointing left"></lion-icon>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Styling
|
|
||||||
|
|
||||||
By default, a `lion-icon` will be `1em` × `1em` (the current line-height).
|
|
||||||
|
|
||||||
`lion-icon` uses SVGs and may be styled with CSS, including using CSS properties such as `fill` and `stroke`:
|
|
||||||
|
|
||||||
<Story name="Styling">
|
|
||||||
{html`
|
|
||||||
<style>
|
|
||||||
lion-icon.custom {
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
fill: blue;
|
|
||||||
stroke: red;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<lion-icon class="custom" .svg="${bugs.bug02}" aria-label="Pointing left"></lion-icon>
|
|
||||||
`}
|
|
||||||
</Story>
|
|
||||||
|
|
||||||
```html
|
|
||||||
<style>
|
|
||||||
lion-icon {
|
|
||||||
fill: blue;
|
|
||||||
stroke: lightsteelblue;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<lion-icon .icon="${bug02}"></lion-icon>
|
|
||||||
```
|
|
||||||
|
|
||||||
See <a href="https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/SVG_and_CSS" target="_blank">SVG and CSS</a> on MDN web docs for more information.
|
|
||||||
|
|
||||||
### Collections
|
|
||||||
|
|
||||||
Due to the `.svg.js` format using ES Modules, it is very easy to compose and load your own icon collections.
|
|
||||||
|
|
||||||
You can bundle them like this:
|
|
||||||
|
|
||||||
```js
|
|
||||||
import bug01 from './bugs/bug01.svg.js';
|
|
||||||
import bug02 from './bugs/bug02.svg.js';
|
|
||||||
import bug05 from './bugs/bug05.svg.js';
|
|
||||||
import bug06 from './bugs/bug06.svg.js';
|
|
||||||
import bug08 from './bugs/bug08.svg.js';
|
|
||||||
import bug12 from './bugs/bug12.svg.js';
|
|
||||||
import bug19 from './bugs/bug19.svg.js';
|
|
||||||
import bug23 from './bugs/bug23.svg.js';
|
|
||||||
import bug24 from './bugs/bug24.svg.js';
|
|
||||||
|
|
||||||
export { bug01, bug02, bug05, bug06, bug08, bug12, bug19, bug23, bug24 };
|
|
||||||
```
|
|
||||||
|
|
||||||
<Story name="Collections">
|
|
||||||
{html`
|
|
||||||
<style>
|
|
||||||
.icon-collection lion-icon {
|
|
||||||
height: 50px;
|
|
||||||
width: 50px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<div class="icon-collection">
|
|
||||||
<lion-icon .svg=${bugs.bug05} aria-label="Skinny dung beatle"></lion-icon>
|
|
||||||
<lion-icon .svg=${bugs.bug06} aria-label="Butterfly"></lion-icon>
|
|
||||||
<lion-icon .svg=${bugs.bug08} aria-label="Ant"></lion-icon>
|
|
||||||
<lion-icon .svg=${bugs.bug12} aria-label="Striped beatle"></lion-icon>
|
|
||||||
<lion-icon .svg=${bugs.bug19} aria-label="Beatle with long whiskers"></lion-icon>
|
|
||||||
<lion-icon .svg=${bugs.bug23} aria-label="Swim beatle"></lion-icon>
|
|
||||||
<lion-icon .svg=${bugs.bug24} aria-label="Big forrest ant"></lion-icon>
|
|
||||||
</div>
|
|
||||||
`}
|
|
||||||
</Story>
|
|
||||||
|
|
||||||
And then use them by either importing them all:
|
|
||||||
|
|
||||||
```js
|
|
||||||
import * as bugs from './icons/bugs-collection.js';
|
|
||||||
```
|
|
||||||
|
|
||||||
Or one by one:
|
|
||||||
|
|
||||||
```js
|
|
||||||
import {
|
|
||||||
bug01,
|
|
||||||
bug02,
|
|
||||||
bug05,
|
|
||||||
bug06,
|
|
||||||
bug08,
|
|
||||||
bug12,
|
|
||||||
bug19,
|
|
||||||
bug23,
|
|
||||||
bug24,
|
|
||||||
} from './icons/bugs-collection.js';
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dynamic import
|
|
||||||
|
|
||||||
It is also possible to dynamically import the `.svg.js` file.
|
|
||||||
This will load the icon asynchronously.
|
|
||||||
|
|
||||||
<Story id="icons-system-internal--dynamic-icon" />
|
|
||||||
|
|
||||||
```js
|
|
||||||
<lion-icon .svg=${import('./icons/bugs/bug05.svg.js')} aria-label="Skinny dung beatle"></lion-icon>
|
|
||||||
```
|
|
||||||
|
|
||||||
The demo is currently disabled for this feature due to an issue with Storybook.
|
|
||||||
135
packages/icon/stories/system.stories.mdx
Normal file
135
packages/icon/stories/system.stories.mdx
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { Story, Meta, html } from '@open-wc/demoing-storybook';
|
||||||
|
import * as bugs from './icons/iconset-bugs.js';
|
||||||
|
import '../lion-icon.js';
|
||||||
|
import './icon-resolvers.js';
|
||||||
|
|
||||||
|
<Meta title="Icons/System" />
|
||||||
|
|
||||||
|
# Icon system
|
||||||
|
|
||||||
|
The icon system provides a way of defining icon sets which are lazily loaded on demand when
|
||||||
|
icons are rendered on the page. This way icon imports do not block the initial render of your
|
||||||
|
application, and you don't need to worry about carefully coordinating the dynamic imports of your icons.
|
||||||
|
|
||||||
|
## Icon format
|
||||||
|
|
||||||
|
For security reasons, icons are defined using lit-html templates to guarantee XSS safety:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import html from 'lit-html';
|
||||||
|
|
||||||
|
export default html`
|
||||||
|
<svg focusable="false" ...>...</svg>
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
The icon can also be a function. In this case, it's possible to reuse the icons if a
|
||||||
|
rendering mechanism different from lit-html is used to process a string.
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default tag => tag`
|
||||||
|
<svg focusable="false" ...>...</svg>
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures the same version of lit-html and the icon system used. This is the recommended approach.
|
||||||
|
|
||||||
|
### Icon accessibility
|
||||||
|
|
||||||
|
On IE11 and some versions of Edge, SVG elements are focusable by default.
|
||||||
|
Setting `focusable="false"` on the SVG prevents this.
|
||||||
|
|
||||||
|
## Iconsets
|
||||||
|
|
||||||
|
Requesting many individual icons can be bad for performance. We should, therefore, group related icons
|
||||||
|
together in icon sets.
|
||||||
|
|
||||||
|
Iconsets are managed by the `IconManager`, where you can register icon resolvers to resolve an icon id
|
||||||
|
to the correct icon.
|
||||||
|
|
||||||
|
### Creating an icon resolver
|
||||||
|
|
||||||
|
An icon resolver is a function that receives the icon set and the icon name and subsequently
|
||||||
|
returns the icon to be rendered.
|
||||||
|
|
||||||
|
The most common use case is for this function to be async, and import the icon set on demand:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function resolveLionIcon(iconset, name) {
|
||||||
|
switch (iconset) {
|
||||||
|
case 'bugs':
|
||||||
|
return import('./icons/iconset-bugs.js').then(module => module[name]);
|
||||||
|
case 'space':
|
||||||
|
return import('./icons/iconset-space.js').then(module => module[name]);
|
||||||
|
case 'misc':
|
||||||
|
return import('./icons/iconset-misc.js').then(module => module[name]);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown iconset ${iconset}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
An icon resolver can also be synchronous, returning the icon directly:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const icons = {
|
||||||
|
coolIcons: {
|
||||||
|
'my-icon': html`
|
||||||
|
<svg>... icon code ...</svg>
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveLionIcon(iconset, name) {
|
||||||
|
return coolIcons[iconSets][name];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registering an icon resolver
|
||||||
|
|
||||||
|
Icon resolvers are registered in the `IconManager` on a namespace. There can be only one resolver per namespace, so
|
||||||
|
make sure they are unique. A good idea is to use your package name as the namespace.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { icons } from '@lion/icon';
|
||||||
|
|
||||||
|
function resolveLionIcon(iconset, name) {
|
||||||
|
switch (iconset) {
|
||||||
|
case 'bugs':
|
||||||
|
return import('./icons/iconset-bugs.js').then(module => module[name]);
|
||||||
|
case 'space':
|
||||||
|
return import('./icons/iconset-space.js').then(module => module[name]);
|
||||||
|
case 'misc':
|
||||||
|
return import('./icons/iconset-misc.js').then(module => module[name]);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown iconset ${iconset}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
icons.addIconResolver('lion', resolveLionIcon);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using icon resolvers
|
||||||
|
|
||||||
|
After register an icon resolver, icons can be resolved from the manager:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { icons } from '@lion/icon';
|
||||||
|
|
||||||
|
const spaceshipIcon = await icons.resolveIcon('lion', 'space', 'alienSpaceship');
|
||||||
|
```
|
||||||
|
|
||||||
|
Icons can also be resolved from a single string, using the pattern: `namespace:iconset:name`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { icons } from '@lion/icon';
|
||||||
|
|
||||||
|
const spaceshipIcon = await icons.resolveIconForId('lion:space:alienSpaceship');
|
||||||
|
```
|
||||||
|
|
||||||
|
This syntax is used by the `lion-icon` component, where the id can be set on an attribute:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<lion-icon icon-id="lion:space:alienSpaceship"></lion-icon>
|
||||||
|
<lion-icon icon-id="lion:misc:arrowLeft"></lion-icon>
|
||||||
|
```
|
||||||
74
packages/icon/test/IconManager.test.js
Normal file
74
packages/icon/test/IconManager.test.js
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { expect } from '@open-wc/testing';
|
||||||
|
import { stub } from 'sinon';
|
||||||
|
import { IconManager } from '../src/IconManager.js';
|
||||||
|
|
||||||
|
describe('IconManager', () => {
|
||||||
|
it('starts off with an empty map of resolvers', () => {
|
||||||
|
const manager = new IconManager();
|
||||||
|
expect(manager.__iconResolvers.size).to.equal(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows adding an icon resolver', () => {
|
||||||
|
const manager = new IconManager();
|
||||||
|
const resolver = () => {};
|
||||||
|
manager.addIconResolver('foo', resolver);
|
||||||
|
|
||||||
|
expect(manager.__iconResolvers.get('foo')).to.equal(resolver);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not allow adding a resolve for the same namespace twice', () => {
|
||||||
|
const manager = new IconManager();
|
||||||
|
manager.addIconResolver('foo', () => {});
|
||||||
|
|
||||||
|
expect(() => manager.addIconResolver('foo', () => {})).to.throw();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can resolve an icon, specifying separate parameters', async () => {
|
||||||
|
const manager = new IconManager();
|
||||||
|
const fooResolver = stub();
|
||||||
|
fooResolver.callsFake(() => 'my icon');
|
||||||
|
const barResolver = stub();
|
||||||
|
manager.addIconResolver('foo', fooResolver);
|
||||||
|
manager.addIconResolver('bar', barResolver);
|
||||||
|
|
||||||
|
const icon = await manager.resolveIcon('foo', 'lorem', 'ipsum');
|
||||||
|
|
||||||
|
expect(fooResolver.callCount).to.equal(1);
|
||||||
|
expect(barResolver.callCount).to.equal(0);
|
||||||
|
expect(fooResolver.withArgs('lorem', 'ipsum').callCount).to.equal(1);
|
||||||
|
expect(icon).to.equal('my icon');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when an incorrect namespace is given', async () => {
|
||||||
|
const manager = new IconManager();
|
||||||
|
const fooResolver = stub();
|
||||||
|
fooResolver.callsFake(() => 'my icon');
|
||||||
|
manager.addIconResolver('foo', fooResolver);
|
||||||
|
|
||||||
|
expect(() => manager.resolveIcon('bar', 'lorem', 'ipsum')).to.throw();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can resolve an icon, specifying parameters as a single string', async () => {
|
||||||
|
const manager = new IconManager();
|
||||||
|
const fooResolver = stub();
|
||||||
|
fooResolver.callsFake(() => 'my icon');
|
||||||
|
manager.addIconResolver('foo', fooResolver);
|
||||||
|
|
||||||
|
const icon = await manager.resolveIconForId('foo:lorem:ipsum');
|
||||||
|
|
||||||
|
expect(fooResolver.callCount).to.equal(1);
|
||||||
|
expect(fooResolver.withArgs('lorem', 'ipsum').callCount).to.equal(1);
|
||||||
|
expect(icon).to.equal('my icon');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when an incorrectly formatted icon id is given', async () => {
|
||||||
|
const manager = new IconManager();
|
||||||
|
const fooResolver = stub();
|
||||||
|
fooResolver.callsFake(() => 'my icon');
|
||||||
|
manager.addIconResolver('foo', fooResolver);
|
||||||
|
|
||||||
|
expect(() => manager.resolveIconForId('lorem:ipsum')).to.throw();
|
||||||
|
expect(() => manager.resolveIconForId('lorem')).to.throw();
|
||||||
|
expect(() => manager.resolveIconForId('foo:lorem:ipsum:bar')).to.throw();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { expect, fixture, fixtureSync, aTimeout, html } from '@open-wc/testing';
|
import { expect, fixture, fixtureSync, aTimeout, html } from '@open-wc/testing';
|
||||||
import { until, render } from '@lion/core';
|
import { until } from '@lion/core';
|
||||||
|
import { icons } from '../src/icons.js';
|
||||||
|
|
||||||
import heartSvg from './heart.svg.js';
|
import heartSvg from './heart.svg.js';
|
||||||
import hammerSvg from './hammer.svg.js';
|
import hammerSvg from './hammer.svg.js';
|
||||||
|
|
@ -142,45 +143,6 @@ describe('lion-icon', () => {
|
||||||
expect(elHammer.getAttribute('aria-hidden')).to.equal('false');
|
expect(elHammer.getAttribute('aria-hidden')).to.equal('false');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('supports dynamic icons using promises', async () => {
|
|
||||||
const el = await fixture(
|
|
||||||
html`
|
|
||||||
<lion-icon
|
|
||||||
.svg=${import('./heart.svg.js').then(e => e.default)}
|
|
||||||
aria-label="Love"
|
|
||||||
></lion-icon>
|
|
||||||
`,
|
|
||||||
);
|
|
||||||
await el.svg;
|
|
||||||
await el.updateComplete;
|
|
||||||
expect(el.children[0].getAttribute('data-test-id')).to.equal('svg-heart');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the default export, by default', async () => {
|
|
||||||
const el = await fixture(
|
|
||||||
html`
|
|
||||||
<lion-icon .svg=${import('./heart.svg.js')} aria-label="Love"></lion-icon>
|
|
||||||
`,
|
|
||||||
);
|
|
||||||
await el.svg;
|
|
||||||
await el.updateComplete;
|
|
||||||
expect(el.children[0].getAttribute('data-test-id')).to.equal('svg-heart');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports dynamic icon bundles', async () => {
|
|
||||||
const el = await fixture(
|
|
||||||
html`
|
|
||||||
<lion-icon
|
|
||||||
.svg=${import('./myIcon.bundle.js').then(e => e.heart)}
|
|
||||||
aria-label="Love"
|
|
||||||
></lion-icon>
|
|
||||||
`,
|
|
||||||
);
|
|
||||||
await el.svg;
|
|
||||||
await el.updateComplete;
|
|
||||||
expect(el.children[0].getAttribute('data-test-id')).to.equal('svg-heart');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports dynamic icons using until directive', async () => {
|
it('supports dynamic icons using until directive', async () => {
|
||||||
const svgLoading = new Promise(resolve => {
|
const svgLoading = new Promise(resolve => {
|
||||||
window.addEventListener('importDone', resolve);
|
window.addEventListener('importDone', resolve);
|
||||||
|
|
@ -233,84 +195,67 @@ describe('lion-icon', () => {
|
||||||
expect(el.innerHTML).to.equal('<!----><!---->'); // don't use lightDom.to.equal(''), it gives false positives
|
expect(el.innerHTML).to.equal('<!----><!---->'); // don't use lightDom.to.equal(''), it gives false positives
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('race conditions with dynamic promisified icons', () => {
|
it('supports icons using an icon id', async () => {
|
||||||
async function prepareRaceCondition(...svgs) {
|
try {
|
||||||
const container = fixtureSync(`<div></div>`);
|
icons.addIconResolver('foo', () => heartSvg);
|
||||||
const resolves = svgs.map(svg => {
|
const el = await fixture(
|
||||||
let resolveSvg;
|
html`
|
||||||
|
<lion-icon icon-id="foo:lorem:ipsum"></lion-icon>
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
const svgProperty =
|
expect(el.children[0].dataset.testId).to.equal('svg-heart');
|
||||||
Promise.resolve(svg) === svg
|
} finally {
|
||||||
? new Promise(resolve => {
|
icons.removeIconResolver('foo');
|
||||||
resolveSvg = () => resolve(svg);
|
|
||||||
})
|
|
||||||
: svg;
|
|
||||||
|
|
||||||
render(
|
|
||||||
html`
|
|
||||||
<lion-icon .svg=${svgProperty}></lion-icon>
|
|
||||||
`,
|
|
||||||
container,
|
|
||||||
);
|
|
||||||
|
|
||||||
return resolveSvg;
|
|
||||||
});
|
|
||||||
|
|
||||||
const icon = container.children[0];
|
|
||||||
await icon.updateComplete;
|
|
||||||
return [icon, ...resolves];
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('renders in the order of rendering instead of the order of resolution', async () => {
|
it('clears rendered icon when icon id is removed', async () => {
|
||||||
let resolveHeartSvg;
|
try {
|
||||||
let resolveHammerSvg;
|
icons.addIconResolver('foo', () => heartSvg);
|
||||||
let icon;
|
const el = await fixture(
|
||||||
let svg;
|
html`
|
||||||
|
<lion-icon icon-id="foo:lorem:ipsum"></lion-icon>
|
||||||
[icon, resolveHeartSvg, resolveHammerSvg] = await prepareRaceCondition(
|
`,
|
||||||
Promise.resolve(heartSvg),
|
|
||||||
Promise.resolve(hammerSvg),
|
|
||||||
);
|
);
|
||||||
resolveHeartSvg();
|
await el.updateComplete;
|
||||||
resolveHammerSvg();
|
el.removeAttribute('icon-id');
|
||||||
await aTimeout();
|
await el.updateComplete;
|
||||||
[svg] = icon.children;
|
expect(el.children.length).to.equal(0);
|
||||||
expect(svg).to.exist;
|
} finally {
|
||||||
expect(svg.getAttribute('data-test-id')).to.equal('svg-hammer');
|
icons.removeIconResolver('foo');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
[icon, resolveHeartSvg, resolveHammerSvg] = await prepareRaceCondition(
|
it('does not create race conditions when icon changed while resolving icon id', async () => {
|
||||||
Promise.resolve(heartSvg),
|
try {
|
||||||
Promise.resolve(hammerSvg),
|
icons.addIconResolver(
|
||||||
|
'foo',
|
||||||
|
() => new Promise(resolve => setTimeout(() => resolve(heartSvg), 10)),
|
||||||
|
);
|
||||||
|
icons.addIconResolver(
|
||||||
|
'bar',
|
||||||
|
() => new Promise(resolve => setTimeout(() => resolve(hammerSvg), 4)),
|
||||||
|
);
|
||||||
|
const el = await fixture(
|
||||||
|
html`
|
||||||
|
<lion-icon icon-id="foo:lorem:ipsum"></lion-icon>
|
||||||
|
`,
|
||||||
);
|
);
|
||||||
resolveHammerSvg();
|
|
||||||
resolveHeartSvg();
|
|
||||||
await aTimeout();
|
|
||||||
[svg] = icon.children;
|
|
||||||
expect(svg).to.exist;
|
|
||||||
expect(svg.getAttribute('data-test-id')).to.equal('svg-hammer');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders if a resolved promise was replaced by a string', async () => {
|
await el.updateComplete;
|
||||||
const [icon, resolveHeartSvg] = await prepareRaceCondition(
|
el.iconId = 'bar:lorem:ipsum';
|
||||||
Promise.resolve(heartSvg),
|
await el.updateComplete;
|
||||||
hammerSvg,
|
await aTimeout(4);
|
||||||
);
|
|
||||||
resolveHeartSvg();
|
|
||||||
await aTimeout();
|
|
||||||
const [svg] = icon.children;
|
|
||||||
expect(svg).to.exist;
|
|
||||||
expect(svg.getAttribute('data-test-id')).to.equal('svg-hammer');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not render if a resolved promise was replaced by another unresolved promise', async () => {
|
// heart is still loading at this point, but hammer came later so that should be later
|
||||||
const [icon, resolveHeartSvg] = await prepareRaceCondition(
|
expect(el.children[0].dataset.testId).to.equal('svg-hammer');
|
||||||
Promise.resolve(heartSvg),
|
await aTimeout(10);
|
||||||
Promise.resolve(hammerSvg),
|
// heart finished loading, but it should not be rendered because hammer came later
|
||||||
);
|
expect(el.children[0].dataset.testId).to.equal('svg-hammer');
|
||||||
resolveHeartSvg();
|
} finally {
|
||||||
await aTimeout();
|
icons.removeIconResolver('foo');
|
||||||
const [svg] = icon.children;
|
icons.removeIconResolver('bar');
|
||||||
expect(svg).to.not.exist;
|
}
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default as heart } from './heart.svg.js';
|
|
||||||
|
|
@ -4,10 +4,12 @@ import { Story, Meta, html } from '@open-wc/demoing-storybook';
|
||||||
|
|
||||||
# Icons
|
# Icons
|
||||||
|
|
||||||
|
Icon system for managing iconsets, taking into account performance, maintainability and scalability.
|
||||||
|
|
||||||
Icons are SVGs so they can be easily scaled and styled with CSS.
|
Icons are SVGs so they can be easily scaled and styled with CSS.
|
||||||
|
|
||||||
## Packages
|
## Packages
|
||||||
|
|
||||||
| Package | Version | Description |
|
| Package | Version | Description |
|
||||||
| --------------------------------------------- | --------------------------------------------------------------------------------------------------- | --------------- |
|
| --------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------- |
|
||||||
| [icon](?path=/docs/icons-icon--default-story) | [](https://www.npmjs.com/package/@lion/icon) | Icon |
|
| [icon](?path=/docs/icons-icon--default-story) | [](https://www.npmjs.com/package/@lion/icon) | Icon |
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue