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 { icons } from './src/icons.js';
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@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 { 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
|
||||
* @polymerElement
|
||||
*/
|
||||
export class LionIcon extends LitElement {
|
||||
static get properties() {
|
||||
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: {
|
||||
type: Object,
|
||||
},
|
||||
role: {
|
||||
type: String,
|
||||
attribute: 'role',
|
||||
reflect: true,
|
||||
},
|
||||
/**
|
||||
* @desc The iconId allows to access icons that are registered to the IconManager
|
||||
* For instance, "lion:space:alienSpaceship"
|
||||
* @type {string}
|
||||
*/
|
||||
ariaLabel: {
|
||||
type: String,
|
||||
attribute: 'aria-label',
|
||||
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')) {
|
||||
this._onLabelChanged(changedProperties);
|
||||
}
|
||||
|
||||
if (changedProperties.has('iconId')) {
|
||||
this._onIconIdChanged(changedProperties.get('iconId'));
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
|
|
@ -85,16 +122,8 @@ export class LionIcon extends LitElement {
|
|||
this.__svg = svg;
|
||||
if (svg === undefined || svg === null) {
|
||||
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 {
|
||||
this._renderSvg(this.constructor.__unwrapSvg(svg));
|
||||
this._renderSvg(unwrapSvg(svg));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -112,21 +141,24 @@ export class LionIcon extends LitElement {
|
|||
}
|
||||
|
||||
_renderSvg(svgObject) {
|
||||
this.constructor.__validateSvg(svgObject);
|
||||
validateSvg(svgObject);
|
||||
render(svgObject, this);
|
||||
}
|
||||
|
||||
static __unwrapSvg(wrappedSvgObject) {
|
||||
const svgObject =
|
||||
wrappedSvgObject && wrappedSvgObject.default ? wrappedSvgObject.default : wrappedSvgObject;
|
||||
return typeof svgObject === 'function' ? svgObject(html) : svgObject;
|
||||
async _onIconIdChanged(prevIconId) {
|
||||
if (!this.iconId) {
|
||||
// clear if switching from iconId to no iconId
|
||||
if (prevIconId) {
|
||||
this.svg = null;
|
||||
}
|
||||
} else {
|
||||
const iconIdBeforeResolve = this.iconId;
|
||||
const svg = await icons.resolveIconForId(iconIdBeforeResolve);
|
||||
|
||||
static __validateSvg(svg) {
|
||||
if (!(svg === nothing || svg instanceof TemplateResult)) {
|
||||
throw new Error(
|
||||
'icon accepts only lit-html templates or functions like "tag => tag`<svg>...</svg>`"',
|
||||
);
|
||||
// update SVG if it did not change in the meantime to avoid race conditions
|
||||
if (this.iconId === iconIdBeforeResolve) {
|
||||
this.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 { until, render } from '@lion/core';
|
||||
import { until } from '@lion/core';
|
||||
import { icons } from '../src/icons.js';
|
||||
|
||||
import heartSvg from './heart.svg.js';
|
||||
import hammerSvg from './hammer.svg.js';
|
||||
|
|
@ -142,45 +143,6 @@ describe('lion-icon', () => {
|
|||
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 () => {
|
||||
const svgLoading = new Promise(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
|
||||
});
|
||||
|
||||
describe('race conditions with dynamic promisified icons', () => {
|
||||
async function prepareRaceCondition(...svgs) {
|
||||
const container = fixtureSync(`<div></div>`);
|
||||
const resolves = svgs.map(svg => {
|
||||
let resolveSvg;
|
||||
|
||||
const svgProperty =
|
||||
Promise.resolve(svg) === svg
|
||||
? new Promise(resolve => {
|
||||
resolveSvg = () => resolve(svg);
|
||||
})
|
||||
: svg;
|
||||
|
||||
render(
|
||||
it('supports icons using an icon id', async () => {
|
||||
try {
|
||||
icons.addIconResolver('foo', () => heartSvg);
|
||||
const el = await fixture(
|
||||
html`
|
||||
<lion-icon .svg=${svgProperty}></lion-icon>
|
||||
<lion-icon icon-id="foo:lorem:ipsum"></lion-icon>
|
||||
`,
|
||||
container,
|
||||
);
|
||||
|
||||
return resolveSvg;
|
||||
});
|
||||
|
||||
const icon = container.children[0];
|
||||
await icon.updateComplete;
|
||||
return [icon, ...resolves];
|
||||
expect(el.children[0].dataset.testId).to.equal('svg-heart');
|
||||
} finally {
|
||||
icons.removeIconResolver('foo');
|
||||
}
|
||||
});
|
||||
|
||||
it('renders in the order of rendering instead of the order of resolution', async () => {
|
||||
let resolveHeartSvg;
|
||||
let resolveHammerSvg;
|
||||
let icon;
|
||||
let svg;
|
||||
|
||||
[icon, resolveHeartSvg, resolveHammerSvg] = await prepareRaceCondition(
|
||||
Promise.resolve(heartSvg),
|
||||
Promise.resolve(hammerSvg),
|
||||
it('clears rendered icon when icon id is removed', async () => {
|
||||
try {
|
||||
icons.addIconResolver('foo', () => heartSvg);
|
||||
const el = await fixture(
|
||||
html`
|
||||
<lion-icon icon-id="foo:lorem:ipsum"></lion-icon>
|
||||
`,
|
||||
);
|
||||
resolveHeartSvg();
|
||||
resolveHammerSvg();
|
||||
await aTimeout();
|
||||
[svg] = icon.children;
|
||||
expect(svg).to.exist;
|
||||
expect(svg.getAttribute('data-test-id')).to.equal('svg-hammer');
|
||||
|
||||
[icon, resolveHeartSvg, resolveHammerSvg] = await prepareRaceCondition(
|
||||
Promise.resolve(heartSvg),
|
||||
Promise.resolve(hammerSvg),
|
||||
);
|
||||
resolveHammerSvg();
|
||||
resolveHeartSvg();
|
||||
await aTimeout();
|
||||
[svg] = icon.children;
|
||||
expect(svg).to.exist;
|
||||
expect(svg.getAttribute('data-test-id')).to.equal('svg-hammer');
|
||||
await el.updateComplete;
|
||||
el.removeAttribute('icon-id');
|
||||
await el.updateComplete;
|
||||
expect(el.children.length).to.equal(0);
|
||||
} finally {
|
||||
icons.removeIconResolver('foo');
|
||||
}
|
||||
});
|
||||
|
||||
it('renders if a resolved promise was replaced by a string', async () => {
|
||||
const [icon, resolveHeartSvg] = await prepareRaceCondition(
|
||||
Promise.resolve(heartSvg),
|
||||
hammerSvg,
|
||||
it('does not create race conditions when icon changed while resolving icon id', async () => {
|
||||
try {
|
||||
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>
|
||||
`,
|
||||
);
|
||||
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 () => {
|
||||
const [icon, resolveHeartSvg] = await prepareRaceCondition(
|
||||
Promise.resolve(heartSvg),
|
||||
Promise.resolve(hammerSvg),
|
||||
);
|
||||
resolveHeartSvg();
|
||||
await aTimeout();
|
||||
const [svg] = icon.children;
|
||||
expect(svg).to.not.exist;
|
||||
});
|
||||
await el.updateComplete;
|
||||
el.iconId = 'bar:lorem:ipsum';
|
||||
await el.updateComplete;
|
||||
await aTimeout(4);
|
||||
|
||||
// heart is still loading at this point, but hammer came later so that should be later
|
||||
expect(el.children[0].dataset.testId).to.equal('svg-hammer');
|
||||
await aTimeout(10);
|
||||
// heart finished loading, but it should not be rendered because hammer came later
|
||||
expect(el.children[0].dataset.testId).to.equal('svg-hammer');
|
||||
} finally {
|
||||
icons.removeIconResolver('foo');
|
||||
icons.removeIconResolver('bar');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
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.
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Version | Description |
|
||||
| --------------------------------------------- | --------------------------------------------------------------------------------------------------- | --------------- |
|
||||
| --------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------- |
|
||||
| [icon](?path=/docs/icons-icon--default-story) | [](https://www.npmjs.com/package/@lion/icon) | Icon |
|
||||
|
|
|
|||
Loading…
Reference in a new issue