2
0
mirror of https://github.com/iconify/iconify.git synced 2025-01-06 07:20:40 +00:00

Web component: first commit

This commit is contained in:
Vjacheslav Trushkin 2022-04-29 23:19:22 +03:00
parent 2c2a9647fa
commit b183dade9d
39 changed files with 17079 additions and 0 deletions

View File

@ -6,6 +6,7 @@
"packages/api-redundancy",
"packages/utils",
"packages/core",
"packages/icon",
"packages/*",
"demo/*",
"debug_packages/*"

View File

@ -0,0 +1,3 @@
lib
dist
tests-compiled

View File

@ -0,0 +1,24 @@
module.exports = {
env: {
browser: true,
es6: true,
node: true,
},
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
globals: {
Atomics: 'readonly',
SharedArrayBuffer: 'readonly',
},
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
rules: {
'no-mixed-spaces-and-tabs': ['off'],
'no-unused-vars': ['off'],
// '@typescript-eslint/no-unused-vars-experimental': ['error'],
},
overrides: [
{
files: ['src/**/*.ts', 'tests/*.ts'],
},
],
};

8
packages/icon/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.DS_Store
npm-debug.log
yarn.lock
tsconfig.tsbuildinfo
node_modules
dist
lib
tests-compiled

17
packages/icon/.npmignore Normal file
View File

@ -0,0 +1,17 @@
.DS_Store
.eslintignore
.eslintrc.js
api-extractor*.json
tsconfig*.json
rollup.config.js
build.js
npm-debug.log
yarn.lock
tsconfig.tsbuildinfo
jest.config.js
node_modules
src
lib
tests
tests-compiled
demo

341
packages/icon/README.md Normal file
View File

@ -0,0 +1,341 @@
# What is Iconify?
Iconify is the most versatile icon framework.
- Unified icon framework that can be used with any icon library.
- Out of the box includes 100+ icon sets with more than 100,000 icons.
- Embed icons in HTML with SVG framework or components for front-end frameworks.
- Embed icons in designs with plug-ins for Figma, Sketch and Adobe XD.
- Add icon search to your applications with Iconify Icon Finder.
For more information visit [https://iconify.design/](https://iconify.design/).
# Iconify SVG framework
There are many fonts and SVG sets available, but they all have one thing in common: using any font or SVG set limits you to icons that are included in that set and forces browsers to load entire font or icons set. That limits developers to one or two fonts or icon sets.
Iconify uses a new innovative approach to loading icons. Unlike fonts and SVG frameworks, Iconify only loads icons that are used on the page instead of loading entire fonts. How is it done? By serving icons dynamically from publicly available JSON API (you can make a copy of script and API if you prefer to keep everything on your servers).
Iconify SVG framework is designed to be as easy to use as possible.
Add this line to your page to load Iconify SVG framework (you can add it to `<head>` section of the page or before `</body>`):
```html
<script src="https://code.iconify.design/2/0.0.1-dev/iconify.min.js"></script>
```
or
```html
<script src="https://cdn.jsdelivr.net/npm/@iconify/iconify@0.0.1-dev/dist/iconify.min.js"></script>
```
or, if you are building a project with something like WebPack or Rollup, you can include the script by installing `@iconify/iconify` as a dependency and importing it in your project:
```js
import Iconify from '@iconify/iconify';
```
To add any icon, write something like this:
```html
<span class="iconify" data-icon="eva:people-outline"></span>
```
&nbsp;&nbsp;&nbsp; ![Sample](https://iconify.design/assets/images/eva-people-outline.svg)
or this:
```html
<span class="iconify-inline" data-icon="fa-solid:home"></span>
<a href="#">Return home!</a>
```
&nbsp;&nbsp;&nbsp; ![Screenshot](https://iconify.design/assets/images/inline-sample.png)
That is it. Change `data-icon` value to the name of the icon you want to use. There are over 100,000 premade icons to choose from, including FontAwesome, Material Design Icons, Tabler Icons, Box Icons, Unicons, Bootstrap Icons and even several emoji sets.
Do you want to make your own icon sets? Everything you need is [available on GitHub](https://github.com/iconify): tools for creating custom icon sets, Iconify API application and documentation to help you.
## Full documentation
Below is a shortened version of documentation.
Full documentation is available on Iconify website:
- [SVG framework documentation](https://docs.iconify.design/implementations/svg-framework/).
- [Iconify API documentation](https://docs.iconify.design/sources/api/).
- [Creating icon bundles](https://docs.iconify.design/sources/bundles/).
- [Iconify Tools documentation](https://docs.iconify.design/tools/node/).
## How does it work?
The syntax is similar to icon fonts. Instead of inserting `SVG` in the document, you write a placeholder element, such `SPAN` or `I`.
Iconify SVG framework finds those placeholders and uses the following logic to parse them:
1. Retrieves icon name from `data-icon` attribute.
2. Checks if icon exists. If not, it sends a request to Iconify API to retrieve icon data.
3. Replaces placeholder element with `SVG`.
This is done in a fraction of a second. Iconify SVG framework watches DOM for changes, so whenever you add new placeholders, it immediately replaces them with `SVG`, making it easy to use with dynamic content, such as AJAX forms.
### Inline mode
Code examples above use different class names: the first example uses "iconify", the second example uses "iconify-inline".
What is the difference?
- "iconify" renders icon as is, so it behaves like an image.
- "iconify-inline" renders adds vertical alignment to the icon, making it behave like text (inline mode).
Usually, icon fonts do not render like normal images, they render like text. Text is aligned slightly below the baseline.
Visual example to show the difference between inline and block modes:
&nbsp;&nbsp;&nbsp; ![Inline icon](https://iconify.design/assets/images/inline.png)
Why is the inline mode needed?
- To easily align icons within the text, such as emojis.
- To make the transition from outdated icon fonts to SVG easier.
Use "iconify" for decorations, use "iconify-inline" if you want the icon to behave like an icon font.
#### data-inline attribute
In addition to using "iconify-inline" class, you can toggle inline mode with the `data-inline` attribute.
Set value to "true" to force inline mode, set value to "false" to use block mode.
Different ways to use block mode:
```html
<span class="iconify" data-icon="eva:people-outline"></span>
<span class="iconify" data-icon="eva:people-outline" data-inline="false"></span>
```
Different ways to use inline mode:
```html
<span class="iconify-inline" data-icon="eva:people-outline"></span>
<span class="iconify" data-icon="eva:people-outline" data-inline="true"></span>
<span
class="iconify"
data-icon="eva:people-outline"
style="vertical-align: -0.125em"
></span>
```
## Iconify API
When you use an icon font, each visitor loads an entire font, even if your page only uses a few icons. This is a major downside of using icon fonts. That limits developers to one or two fonts or icon sets.
Unlike icon fonts, Iconify SVG framework does not load the entire icon set. Unlike fonts and SVG frameworks, Iconify only loads icons that are used on the current page instead of loading entire icon sets. How is it done? By serving icons dynamically from publicly available JSON API.
### Custom API
Relying on a third party service is often not an option. Many companies and developers prefer to keep everything on their own servers to have full control.
Iconify API and icon sets are all [available on GitHub](https://github.com/iconify), making it easy to host API on your own server.
For more details see [Iconify API documentation](https://docs.iconify.design/sources/api/).
You can also create custom Iconify API to serve your own icons. For more details see [hosting custom icons in Iconify documentation](https://iconify.design/docs/api-custom-hosting/).
### Using Iconify offline
While the default method of retrieving icons is to retrieve them from API, there are other options. Iconify SVG framework is designed to be as flexible as possible.
Easiest option to serve icons without API is by creating icon bundles.
Icon bundles are small scripts that you can load after Iconify SVG framework or bundle it together in one file.
For more details see [icon bundles in Iconify documentation](https://iconify.design/docs/icon-bundles/).
Another option is to import icons and bundle them with Iconify, similar to React and Vue components. Example:
```js
// Installation: npm install --save-dev @iconify/iconify
import Iconify from '@iconify/iconify/offline';
// Installation: npm install --save-dev @iconify/icons-dashicons
import adminUsers from '@iconify/icons-dashicons/admin-users';
// Unlike React and Vue components, in SVG framework each icon added with addIcon() name must have a
// prefix and a name. In this example prefix is "dashicons" and name is "admin-users".
Iconify.addIcon('dashicons:admin-users', adminUsers);
```
```html
<span class="iconify" data-icon="dashicons:admin-users"></span>
```
See [Iconify for React](http://github.com/iconify/iconify/packages/react) documentation for more details.
## Color
There are 2 types of icons: monotone and coloured.
- Monotone icons are icons that use only 1 colour and you can change that colour. Most icon sets fall into this category: FontAwesome, Unicons, Material Design Icons, etc.
- Coloured icons are icons that use the preset palette. Most emoji icons fall into this category: Noto Emoji, Emoji One, etc. You cannot change the palette for those icons.
Monotone icons use font colour, just like glyph fonts. To change colour, you can do this:
```html
<span class="iconify icon-bell" data-icon="vaadin-bell"></span>
```
and add this to CSS:
```css
.icon-bell {
color: #f80;
}
.icon-bell:hover {
color: #f00;
}
```
Sample:
&nbsp;&nbsp;&nbsp; ![Sample](https://iconify.design/samples/icon-color.png)
## Dimensions
By default all icons are scaled to 1em height. To control icon height use font-size:
```html
<span class="iconify icon-clipboard" data-icon="emojione-clipboard"></span>
```
and add this to css:
```css
.icon-clipboard {
font-size: 32px;
}
```
Sample:
&nbsp;&nbsp;&nbsp; ![Sample](https://iconify.design/samples/icon-size.png)
you might also need to set line-height:
```css
.icon-clipboard {
font-size: 32px;
line-height: 1em;
}
```
You can also set custom dimensions using `data-width` and `data-height` attributes:
```html
<span
data-icon="twemoji-ice-cream"
data-width="32"
data-height="32"
class="iconify"
></span>
```
Sample:
&nbsp;&nbsp;&nbsp; ![Sample](https://iconify.design/samples/icon-size2.png)
## Transformations
You can rotate and flip icon by adding `data-flip` and `data-rotate` attributes:
```html
<span
data-icon="twemoji-helicopter"
class="iconify"
data-flip="horizontal"
></span>
<span data-icon="twemoji-helicopter" class="iconify" data-rotate="90deg"></span>
```
Possible values for `data-flip`: horizontal, vertical.
Possible values for `data-rotate`: 90deg, 180deg, 270deg.
If you use both flip and rotation, the icon is flipped first, then rotated.
To use custom transformations use CSS transform rule. Add `!important` after rule to override the SVG inline style (inline style exists to fix an SVG rendering bug in Firefox browser).
```html
<span data-icon="twemoji-helicopter" class="iconify icon-helicopter"></span>
```
```css
.icon-helicopter {
transform: 45deg !important;
}
```
Samples:
&nbsp;&nbsp;&nbsp; ![Sample](https://iconify.design/samples/icon-transform.png)
## Available icons
There are over 100,000 icons to choose from.
General collections (monotone icons):
- [Material Design Icons](https://icon-sets.iconify.design/mdi/) (5000+ icons)
- [Unicons](https://icon-sets.iconify.design/uil/) (1000+ icons)
- [Jam Icons](https://icon-sets.iconify.design/jam/) (900 icons)
- [IonIcons](https://icon-sets.iconify.design/ion/) (1200+ icons)
- [FontAwesome 4](https://icon-sets.iconify.design/fa/) and [FontAwesome 5](https://icon-sets.iconify.design/fa-solid/) (2000+ icons)
- [Vaadin Icons](https://icon-sets.iconify.design/vaadin/) (600+ icons)
- [Bootstrap Icons](https://icon-sets.iconify.design/bi/) (500+ icons)
- [Feather Icons](https://icon-sets.iconify.design/feather/) and [Feather Icon](https://icon-sets.iconify.design/fe/) (500+ icons)
- [IcoMoon Free](https://icon-sets.iconify.design/icomoon-free/) (400+ icons)
- [Dashicons](https://icon-sets.iconify.design/dashicons/) (300 icons)
and many others.
Emoji collections (mostly colored icons):
- [Emoji One](https://icon-sets.iconify.design/emojione/) (1800+ colored version 2 icons, 1400+ monotone version 2 icons, 1200+ version 1 icons)
- [OpenMoji](https://icon-sets.iconify.design/openmoji/) (3500+ icons)
- [Noto Emoji](https://icon-sets.iconify.design/noto/) (2000+ icons for version 2, 2000+ icons for version 1)
- [Twitter Emoji](https://icon-sets.iconify.design/twemoji/) (2000+ icons)
- [Firefox OS Emoji](https://icon-sets.iconify.design/fxemoji/) (1000+ icons)
Also, there are several thematic collections, such as weather icons, map icons, etc.
You can use browse or search available icons on the Iconify website: https://icon-sets.iconify.design/
Click an icon to get HTML code.
## Iconify vs SVG vs glyph fonts
Why use Iconify instead of fonts or other frameworks?
There is a tutorial that explains all differences. See http://iconify.design/docs/iconify-svg-fonts/
## Browser support
Iconify supports all modern browsers.
Old browsers that are supported:
- IE 9+
- iOS Safari for iOS 8+
IE 9, 10 and iOS 8 Safari do not support some modern functions that Iconify relies on. Iconify will automatically
load polyfills for those browsers. All newer browsers do not require those polyfills.
## License
This package is licensed under MIT license.
`SPDX-License-Identifier: MIT`
This license does not apply to icons. Icons are released under different licenses, see each icon set for details.
Icons available by default are all licensed under some kind of open-source or free license.
© 2022 Vjacheslav Trushkin / Iconify OÜ

View File

@ -0,0 +1,45 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
"mainEntryPointFilePath": "lib/iconify.d.ts",
"bundledPackages": [
"@iconify/types",
"@iconify/core",
"@iconify/utils",
"@cyberalien/redundancy",
"@iconify/api-redundancy"
],
"compiler": {},
"apiReport": {
"enabled": false
},
"docModel": {
"enabled": false
},
"dtsRollup": {
"enabled": true,
"untrimmedFilePath": "<projectFolder>/dist/iconify.d.ts"
},
"tsdocMetadata": {
"enabled": false
},
"messages": {
"compilerMessageReporting": {
"default": {
"logLevel": "warning"
}
},
"extractorMessageReporting": {
"default": {
"logLevel": "warning"
},
"ae-missing-release-tag": {
"logLevel": "none"
}
},
"tsdocMessageReporting": {
"default": {
"logLevel": "warning"
}
}
}
}

120
packages/icon/build.js Normal file
View File

@ -0,0 +1,120 @@
/* eslint-disable */
const fs = require('fs');
const path = require('path');
const child_process = require('child_process');
const packagesDir = path.dirname(__dirname);
// List of commands to run
const commands = [];
// Parse command line
const compile = {
core: false,
lib: true,
dist: true,
api: true,
};
process.argv.slice(2).forEach((cmd) => {
if (cmd.slice(0, 2) !== '--') {
return;
}
const parts = cmd.slice(2).split('-');
if (parts.length === 2) {
// Parse 2 part commands like --with-lib
const key = parts.pop();
if (compile[key] === void 0) {
return;
}
switch (parts.shift()) {
case 'with':
// enable module
compile[key] = true;
break;
case 'without':
// disable module
compile[key] = false;
break;
case 'only':
// disable other modules
Object.keys(compile).forEach((key2) => {
compile[key2] = key2 === key;
});
break;
}
}
});
// Check if required modules in same monorepo are available
const fileExists = (file) => {
try {
fs.statSync(file);
} catch (e) {
return false;
}
return true;
};
if (compile.dist && !fileExists(packagesDir + '/iconify/lib/iconify.js')) {
compile.lib = true;
}
if (compile.api && !fileExists(packagesDir + '/iconify/lib/iconify.d.ts')) {
compile.lib = true;
}
if (compile.lib && !fileExists(packagesDir + '/core/lib/cache.mjs')) {
compile.core = true;
}
// Compile core before compiling this package
if (compile.core) {
commands.push({
cmd: 'npm',
args: ['run', 'build'],
cwd: packagesDir + '/core',
});
}
// Add api2
if (compile.api) {
compile.api2 = true;
}
// Compile other packages
Object.keys(compile).forEach((key) => {
if (key !== 'core' && compile[key]) {
commands.push({
cmd: 'npm',
args: ['run', 'build:' + key],
});
}
});
/**
* Run next command
*/
const next = () => {
const item = commands.shift();
if (item === void 0) {
process.exit(0);
}
if (item.cwd === void 0) {
item.cwd = __dirname;
}
const result = child_process.spawnSync(item.cmd, item.args, {
cwd: item.cwd,
stdio: 'inherit',
});
if (result.status === 0) {
process.nextTick(next);
} else {
process.exit(result.status);
}
};
next();

View File

@ -0,0 +1,7 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
verbose: true,
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/tests/*-test.ts'],
};

21
packages/icon/license.txt Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Vjacheslav Trushkin / Iconify OÜ
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

14505
packages/icon/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,46 @@
{
"name": "iconify-icon",
"description": "Icon web component that loads icon data on demand. Over 100,000 icons to choose from",
"author": "Vjacheslav Trushkin <cyberalien@gmail.com> (https://iconify.design)",
"version": "0.0.1-dev",
"license": "MIT",
"bugs": "https://github.com/iconify/iconify/issues",
"homepage": "https://iconify.design/",
"funding": "http://github.com/sponsors/cyberalien",
"repository": {
"type": "git",
"url": "https://github.com/iconify/iconify.git",
"directory": "packages/icon"
},
"scripts": {
"clean": "rimraf lib dist tsconfig.tsbuildinfo",
"lint": "eslint src/**/*.ts",
"prebuild": "npm run lint && npm run clean",
"build": "node build",
"build:lib": "tsc -b",
"build:dist": "rollup -c rollup.config.js",
"test:jest": "jest --runInBand",
"test:mjs": "echo \"TODO...\"",
"test": "npm run test:jest && npm run test:mjs"
},
"devDependencies": {
"@iconify/core": "^1.3.2",
"@microsoft/api-extractor": "^7.19.5",
"@rollup/plugin-buble": "^0.21.3",
"@rollup/plugin-node-resolve": "^13.1.3",
"@rollup/plugin-replace": "^4.0.0",
"@types/jest": "^27.4.1",
"@types/jsdom": "^16.2.14",
"@types/node": "^17.0.22",
"@typescript-eslint/eslint-plugin": "^5.16.0",
"cross-env": "^7.0.3",
"eslint": "^8.11.0",
"jest": "^28.0.0-alpha.7",
"jsdom": "^19.0.0",
"rimraf": "^3.0.2",
"rollup": "^2.70.1",
"rollup-plugin-terser": "^7.0.2",
"ts-jest": "^27.1.4",
"typescript": "^4.6.2"
}
}

View File

@ -0,0 +1,146 @@
import { readFileSync, writeFileSync } from 'fs';
import resolve from '@rollup/plugin-node-resolve';
import buble from '@rollup/plugin-buble';
import { terser } from 'rollup-plugin-terser';
import replace from '@rollup/plugin-replace';
const names = ['iconify', 'iconify.without-api'];
const global = 'Iconify';
// Wrapper to export module as global and as ES module
const header = `/**
* (c) Iconify
*
* For the full copyright and license information, please view the license.txt or license.gpl.txt
* files at https://github.com/iconify/iconify
*
* Licensed under Apache 2.0 or GPL 2.0 at your option.
* If derivative product is not compatible with one of licenses, you can pick one of licenses.
*
* @license Apache 2.0
* @license GPL 2.0
* @version __iconify_version__
*/`;
const defaultFooter = `
// Export to window or web worker
try {
if (self.Iconify === void 0) {
self.Iconify = Iconify;
}
} catch (err) {
}`;
const iifeFooter = `
// Export as ES module
if (typeof exports === 'object') {
try {
exports.__esModule = true;
exports.default = Iconify;
for (var key in Iconify) {
exports[key] = Iconify[key];
}
} catch (err) {
}
}
${defaultFooter}`;
// Get replacements
const replacements = {
preventAssignment: true,
};
const packageJSON = JSON.parse(readFileSync('package.json', 'utf8'));
replacements['__iconify_version__'] = packageJSON.version;
// Update README.md
let readme = readFileSync('README.md', 'utf8');
const oldReadme = readme;
const replaceCodeLink = (search) => {
let start = 0;
let pos;
while ((pos = readme.indexOf(search, start)) !== -1) {
start = pos + search.length;
let pos2 = readme.indexOf('/', start);
if (pos2 === -1) {
return;
}
readme =
readme.slice(0, start) + packageJSON.version + readme.slice(pos2);
}
};
replaceCodeLink('/code.iconify.design/2/');
replaceCodeLink('/@iconify/iconify@');
if (readme !== oldReadme) {
console.log('Updatead README');
writeFileSync('README.md', readme, 'utf8');
}
// Export configuration
const config = [];
names.forEach((name) => {
// Full and minified
[false, true].forEach((minify) => {
// Parse all formats
['js', 'cjs', 'mjs'].forEach((ext) => {
if (minify && ext !== 'js') {
// Minify only .js files
return;
}
// Get export format and footer
let format = ext;
let footer = defaultFooter;
switch (ext) {
case 'js':
format = 'iife';
footer = iifeFooter;
break;
case 'mjs':
format = 'es';
break;
}
const item = {
input: `lib/${name}.js`,
output: [
{
file: `dist/${name}${minify ? '.min' : ''}.${ext}`,
format,
exports: 'named',
name: global,
banner: header,
footer,
},
],
plugins: [
resolve({
browser: true,
}),
replace(replacements),
],
};
if (ext === 'js') {
// Support old browsers only in .js files.
// Other files are for modern browsers that don't need it or
// for bundlers that should handle old browser support themselves.
item.plugins.push(
buble({
objectAssign: 'Object.assign',
})
);
}
if (minify) {
item.plugins.push(terser());
}
config.push(item);
});
});
});
export default config;

View File

@ -0,0 +1,60 @@
import type { FullIconCustomisations } from '@iconify/utils/lib/customisations';
import { defaults } from '@iconify/utils/lib/customisations';
import { rotateFromString } from '@iconify/utils/lib/customisations/rotate';
import {
flipFromString,
alignmentFromString,
} from '@iconify/utils/lib/customisations/shorthand';
// Remove 'inline' from defaults
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { inline, ...defaultCustomisations } = defaults;
export { defaultCustomisations };
/**
* Customisations that affect rendering
*/
export type RenderedIconCustomisations = Omit<FullIconCustomisations, 'inline'>;
/**
* Get customisations
*/
export function getCustomisations(node: Element): RenderedIconCustomisations {
const customisations = {
...defaultCustomisations,
};
const attr = (key: string, def: string | null) =>
node.getAttribute(key) || def;
// Dimensions
customisations.width = attr('width', null);
customisations.height = attr('height', null);
// Rotation
customisations.rotate = rotateFromString(attr('rotate', ''));
// Flip
flipFromString(customisations, attr('flip', ''));
// Alignment
alignmentFromString(customisations, attr('align', ''));
return customisations;
}
/**
* Check if customisations have been updated
*/
export function haveCustomisationsChanged(
value1: RenderedIconCustomisations,
value2: RenderedIconCustomisations
): boolean {
for (const key in defaultCustomisations) {
if (value1[key] !== value2[key]) {
return true;
}
}
return false;
}

View File

@ -0,0 +1,56 @@
import type { IconifyIcon } from '@iconify/types';
import type { IconifyIconName } from '@iconify/utils/lib/icon/name';
import { stringToIcon } from '@iconify/utils/lib/icon/name';
import { getIconData } from '@iconify/core/lib/storage/functions';
import { loadIcons } from '@iconify/core/lib/api/icons';
import { testIconObject } from './object';
import type { CurrentIconData } from './state';
/**
* Callback
*/
export type IconOnLoadCallback = (
value: unknown,
name: IconifyIconName,
data?: Required<IconifyIcon> | null
) => void;
/**
* Parse icon value, load if needed
*/
export function parseIconValue(
value: unknown,
onload: IconOnLoadCallback
): CurrentIconData {
// Check if icon name is valid
const name = typeof value === 'string' ? stringToIcon(value, true) : null;
if (!name) {
// Test for serialised object
const data = testIconObject(value);
return {
value,
data,
};
}
// Valid icon name: check if data is available
const data = getIconData(name);
if (data !== void 0) {
return {
value,
name,
data, // could be 'null' -> icon is missing
};
}
// Load icon
const loading = loadIcons([name], () =>
onload(value, name, getIconData(name))
);
return {
value,
name,
loading,
};
}

View File

@ -0,0 +1,21 @@
import type { IconifyIcon } from '@iconify/types';
import { iconDefaults } from '@iconify/utils/lib/icon';
/**
* Test icon string
*/
export function testIconObject(
value: unknown
): Required<IconifyIcon> | undefined {
try {
const obj = typeof value === 'string' ? JSON.parse(value) : value;
if (typeof obj.body === 'string') {
return {
...iconDefaults,
...obj,
};
}
} catch {
//
}
}

View File

@ -0,0 +1,28 @@
import type { IconifyIcon } from '@iconify/types';
import type { IconifyIconName } from '@iconify/utils/lib/icon/name';
import type { IconifyIconLoaderAbort } from '@iconify/core/lib/api/icons';
/**
* Value for currently selected icon
*/
export interface CurrentIconData {
// Value passed as parameter
value: unknown;
// Data, if available. Can be null if icon is missing in API
data?: Required<IconifyIcon> | null;
// Icon name as object, if `value` is a valid icon name
name?: IconifyIconName | null;
// Loader abort function, set if icon is being loaded. Used only when `name` is valid
loading?: IconifyIconLoaderAbort;
}
/**
* Same as above, if
*/
export interface RenderedCurrentIconData extends CurrentIconData {
// Full icon data
data: Required<IconifyIcon>;
}

View File

@ -0,0 +1,6 @@
/**
* Check for inline
*/
export function getInline(node: Element): boolean {
return node.hasAttribute('inline');
}

View File

@ -0,0 +1,24 @@
import type { ActualRenderMode, IconifyRenderMode } from './types';
/**
* Get render mode
*/
export function getRenderMode(body: string, mode?: string): ActualRenderMode {
switch (mode as ActualRenderMode | '') {
// Force mode
case 'svg':
case 'bg':
case 'mask':
return mode as ActualRenderMode;
}
// Check for animation, use 'style' for animated icons
// (only <a>, which should be ignored or animations start with '<a')
if ((mode as IconifyRenderMode) !== 'style' && body.indexOf('<a') === -1) {
// Render <svg>
return 'svg';
}
// Use background or mask
return body.indexOf('currentColor') === -1 ? 'bg' : 'mask';
}

View File

@ -0,0 +1,48 @@
import type { IconifyIcon } from '@iconify/types';
/**
* Icon render modes
*
* 'bg' = SPAN with style using `background`
* 'mask' = SPAN with style using `mask`
* 'svg' = SVG
*/
export type ActualRenderMode = 'bg' | 'mask' | 'svg';
/**
* Extra render modes
*
* 'style' = 'bg' or 'mask', depending on icon content
*/
export type IconifyRenderMode = 'style' | ActualRenderMode;
/**
* Icon customisations
*/
export type IconifyIconCustomisationAttributes = {
// Dimensions
width?: string | number;
height?: string | number;
// Transformations
rotate?: string | number;
flip?: string;
// Alignment
align?: string;
};
/**
* All attributes
*/
export interface IconifyIconAttributes
extends IconifyIconCustomisationAttributes {
// Icon to render: name, object or serialised object
icon: string | IconifyIcon;
// Render mode
mode?: IconifyRenderMode;
// Inline mode
inline?: boolean | string;
}

View File

@ -0,0 +1,323 @@
import {
getCustomisations,
haveCustomisationsChanged,
RenderedIconCustomisations,
} from './attributes/customisations';
import { parseIconValue } from './attributes/icon';
import type {
CurrentIconData,
RenderedCurrentIconData,
} from './attributes/icon/state';
import { getInline } from './attributes/inline';
import { getRenderMode } from './attributes/mode';
import type { IconifyIconAttributes } from './attributes/types';
import { renderIcon } from './render/icon';
import { updateStyle } from './render/style';
import { IconState, setPendingState } from './state';
/**
* Interface
*/
declare interface PartialIconifyIconHTMLElement extends HTMLElement {
// Restart animation for animated icons
restartAnimation: () => void;
}
// Add dynamically generated getters and setters
export declare interface IconifyIconHTMLElement
extends PartialIconifyIconHTMLElement,
Required<IconifyIconAttributes> {}
/**
* Constructor
*/
interface PartialIconifyIconHTMLElementClass {
new (): PartialIconifyIconHTMLElement;
prototype: PartialIconifyIconHTMLElement;
}
export interface IconifyIconHTMLElementClass {
new (): IconifyIconHTMLElement;
prototype: IconifyIconHTMLElement;
}
/**
* Register 'iconify-icon' component, if it does not exist
*/
export function defineIconifyIcon(
name = 'iconify-icon'
): PartialIconifyIconHTMLElementClass | undefined {
// Check for custom elements registry and HTMLElement
let customElements: CustomElementRegistry;
let ParentClass: typeof HTMLElement;
try {
customElements = window.customElements;
ParentClass = window.HTMLElement;
} catch {
return;
}
// Make sure registry and HTMLElement exist
if (!customElements || !ParentClass) {
return;
}
// Check for duplicate
const ConflictingClass = customElements.get(name);
if (ConflictingClass) {
return ConflictingClass as IconifyIconHTMLElementClass;
}
// All attributes
const attributes: (keyof IconifyIconAttributes)[] = [
// Icon
'icon',
// Mode
'mode',
'inline',
// Customisations
'width',
'height',
'rotate',
'flip',
'align',
];
/**
* Component class
*/
class IconifyIcon extends ParentClass {
// Root
_shadowRoot: ShadowRoot;
// State
_state: IconState;
/**
* Constructor
*/
constructor() {
super();
// Attach shadow DOM
const root = (this._shadowRoot = this.attachShadow({
mode: 'closed',
}));
// Add style
const inline = getInline(this);
updateStyle(root, inline);
// Create empty state
const value = this.getAttribute('icon');
this._state = setPendingState(
{
value,
},
inline
);
// Update icon
this._iconChanged(value);
}
/**
* Observed attributes
*/
static get observedAttributes() {
return attributes.slice(0);
}
/**
* Attribute has changed
*/
attributeChangedCallback(
name: string,
oldValue: unknown,
newValue: unknown
) {
const state = this._state;
switch (name as keyof IconifyIconAttributes) {
case 'icon': {
this._iconChanged(newValue);
return;
}
case 'inline': {
const newInline = !!newValue;
if (newInline !== state.inline) {
// Update style if inline mode changed
state.inline = newInline;
updateStyle(this._shadowRoot, newInline);
}
return;
}
case 'mode': {
if (state.rendered) {
// Re-render if icon is currently being renrered
this._renderIcon(state.icon);
}
return;
}
default: {
if (state.rendered) {
// Check customisations only if icon has been rendered
const newCustomisations = getCustomisations(this);
if (
haveCustomisationsChanged(
newCustomisations,
state.customisations
)
) {
// Re-render
this._renderIcon(state.icon, newCustomisations);
}
}
}
}
}
/**
* Get/set icon
*/
get icon() {
const value = this.getAttribute('icon');
if (value && value.slice(0, 1) === '{') {
try {
return JSON.parse(value);
} catch {
//
}
}
return value;
}
set icon(value) {
if (typeof value === 'object') {
value = JSON.stringify(value);
}
this.setAttribute('icon', value);
}
/**
* Get/set inline
*/
get inline() {
return getInline(this);
}
set inline(value) {
this.setAttribute('inline', value ? 'true' : null);
}
/**
* Restart animation
*/
restartAnimation() {
const state = this._state;
if (state.rendered) {
const root = this._shadowRoot;
if (state.renderedMode === 'svg') {
// Update root node
try {
(root.lastChild as SVGSVGElement).setCurrentTime(0);
return;
} catch {
// Failed: setCurrentTime() is not supported
}
}
renderIcon(root, state);
}
}
/**
* Icon value has changed
*/
_iconChanged(newValue: unknown) {
const icon = parseIconValue(newValue, (value, name, data) => {
// Asynchronous callback: re-check values to make sure stuff wasn't changed
const state = this._state;
if (state.rendered || state.icon.value !== value) {
// Icon data is already available or icon attribute was changed
return;
}
// Change icon
const icon: CurrentIconData = {
value,
name,
data,
};
if (icon.data) {
// Render icon
this._renderIcon(icon as RenderedCurrentIconData);
} else {
// Nothing to render: update icon in state
state.icon = icon;
}
});
if (icon.data) {
// Icon is ready to render
this._renderIcon(icon as RenderedCurrentIconData);
} else {
// Pending icon
this._state = setPendingState(
icon,
this._state.inline,
this._state
);
}
}
/**
* Re-render based on icon data
*/
_renderIcon(
icon: RenderedCurrentIconData,
customisations?: RenderedIconCustomisations
) {
// Get mode
const attrMode = this.getAttribute('mode');
const renderedMode = getRenderMode(icon.data.body, attrMode);
// Inline was not changed
const inline = this._state.inline;
// Set state and render
renderIcon(
this._shadowRoot,
(this._state = {
rendered: true,
icon,
inline,
customisations: customisations || getCustomisations(this),
attrMode,
renderedMode,
})
);
}
}
// Add getters and setters
attributes.forEach((attr) => {
if (!Object.hasOwn(IconifyIcon.prototype, attr)) {
Object.defineProperty(IconifyIcon.prototype, attr, {
get: function () {
return this.getAttribute(attr);
},
set: function (value) {
this.setAttribute(attr, value);
},
});
}
});
// Define new component
customElements.define(name, IconifyIcon);
return IconifyIcon;
}

View File

@ -0,0 +1,34 @@
import { iconToSVG } from '@iconify/utils/lib/svg/build';
import type { RenderedState } from '../state';
import { renderSPAN } from './span';
import { renderSVG } from './svg';
/**
* Render icon
*/
export function renderIcon(parent: Element | ShadowRoot, state: RenderedState) {
// Render icon
const iconData = state.icon.data;
const renderData = iconToSVG(iconData, {
...state.customisations,
inline: state.inline,
});
const mode = state.renderedMode;
let node: Element;
switch (mode) {
case 'svg':
node = renderSVG(renderData);
break;
default:
node = renderSPAN(renderData, iconData, mode === 'mask');
}
// Set element
// Assumes first node is a style node created with updateStyle()
if (parent.childNodes.length > 1) {
parent.removeChild(parent.lastChild);
}
parent.appendChild(node);
}

View File

@ -0,0 +1,74 @@
import type { FullIconifyIcon } from '@iconify/utils/lib/icon';
import type { IconifyIconBuildResult } from '@iconify/utils/lib/svg/build';
import { iconToHTML } from '@iconify/utils/lib/svg/html';
import { svgToURL } from '@iconify/utils/lib/svg/url';
// List of properties to apply
const monotoneProps: Record<string, string> = {
'background-color': 'currentColor',
};
const coloredProps: Record<string, string> = {
'background-color': 'transparent',
};
// Dynamically add common props to variables above
const propsToAdd: Record<string, string> = {
image: 'var(--svg)',
repeat: 'no-repeat',
size: '100% 100%',
};
const propsToAddTo: Record<string, Record<string, string>> = {
'-webkit-mask': monotoneProps,
'mask': monotoneProps,
'background': coloredProps,
};
for (const prefix in propsToAddTo) {
const list = propsToAddTo[prefix];
for (const prop in propsToAdd) {
list[prefix + '-' + prop] = propsToAdd[prop];
}
}
/**
* Render node as <span>
*/
export function renderSPAN(
data: IconifyIconBuildResult,
icon: FullIconifyIcon,
useMask: boolean
): Element {
const node = document.createElement('span');
// Body
let body = data.body;
if (body.indexOf('<a') !== -1) {
// Animated SVG: add something to fix timing bug
body += '<!-- ' + Date.now() + ' -->';
}
// Generate SVG as URL
const renderAttribs = data.attributes;
const html = iconToHTML(body, {
...renderAttribs,
width: icon.width + '',
height: icon.height + '',
});
const url = svgToURL(html);
// Generate style
const svgStyle = node.style;
const styles: Record<string, string> = {
'--svg': url,
'width': renderAttribs.width,
'height': renderAttribs.height,
...(useMask ? monotoneProps : coloredProps),
};
// Apply style
for (const prop in styles) {
svgStyle.setProperty(prop, styles[prop]);
}
return node;
}

View File

@ -0,0 +1,17 @@
/**
* Add/update style node
*/
export function updateStyle(parent: Element | ShadowRoot, inline: boolean) {
// Get node, create if needed
let style = parent.firstChild;
if (!style) {
style = document.createElement('style');
parent.appendChild(style);
}
// Update content
style.textContent =
':host{display:inline-block;vertical-align:' +
(inline ? '-0.125em' : '0') +
'}span,svg{display:block}';
}

View File

@ -0,0 +1,13 @@
import type { IconifyIconBuildResult } from '@iconify/utils/lib/svg/build';
import { iconToHTML } from '@iconify/utils/lib/svg/html';
/**
* Render node as <svg>
*/
export function renderSVG(data: IconifyIconBuildResult): Element {
const node = document.createElement('span');
// Generate SVG
node.innerHTML = iconToHTML(data.body, data.attributes);
return node.firstChild as HTMLElement;
}

View File

@ -0,0 +1,72 @@
import type { ActualRenderMode } from '../attributes/types';
import type {
CurrentIconData,
RenderedCurrentIconData,
} from '../attributes/icon/state';
import type { RenderedIconCustomisations } from '../attributes/customisations';
/**
* Common attributes, rendered regardless of loading state
*/
interface BaseState {
inline: boolean;
}
/**
* Successful render
*/
export interface RenderedState extends BaseState {
rendered: true;
// Icon, including data
icon: RenderedCurrentIconData;
// Mode passed as attribute
attrMode?: string;
// Actual mode used to render icon
renderedMode: ActualRenderMode;
// Customisations
customisations: RenderedIconCustomisations;
}
/**
* Pending render
*/
export interface PendingState extends BaseState {
rendered: false;
// Icon, without data
icon: CurrentIconData;
// Last rendered state. Set if icon was not re-renderd, but new state is pending, such as new icon is being loaded.
lastRender?: RenderedState;
}
/**
* Icon
*/
export type IconState = RenderedState | PendingState;
/**
* Set state to PendingState
*/
export function setPendingState(
icon: CurrentIconData,
inline: boolean,
lastState?: IconState
): PendingState {
const lastRender: RenderedState | undefined =
lastState &&
(lastState.rendered
? lastState
: (lastState as PendingState).lastRender);
return {
rendered: false,
inline,
icon,
lastRender,
};
}

View File

@ -0,0 +1,198 @@
import {
cleanupGlobals,
expectedBlock,
expectedInline,
setupDOM,
} from './helpers';
import { defineIconifyIcon, IconifyIconHTMLElement } from '../src/component';
import type { IconState } from '../src/state';
export declare interface DebugIconifyIconHTMLElement
extends IconifyIconHTMLElement {
// Internal stuff, used for debugging
_shadowRoot: ShadowRoot;
_state: IconState;
}
describe('Testing icon component', () => {
afterEach(cleanupGlobals);
it('Creating component instance, changing properties', () => {
// Setup DOM
const doc = setupDOM('').window.document;
// Make sure component does not exist and registry is available
expect(window.customElements).toBeDefined();
expect(window.customElements.get('iconify-icon')).toBeUndefined();
// Define component
expect(defineIconifyIcon()).toBeDefined();
expect(window.customElements.get('iconify-icon')).toBeDefined();
// Create element
const node = document.createElement(
'iconify-icon'
) as DebugIconifyIconHTMLElement;
// Should be empty
expect(node._shadowRoot.innerHTML).toBe(
`<style>${expectedBlock}</style>`
);
// Set icon
node.icon = {
body: '<g />',
};
expect(node.icon).toEqual({
body: '<g />',
});
expect(node.getAttribute('icon')).toBe(
JSON.stringify({
body: '<g />',
})
);
// Should render SVG
const blankSVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 16 16"><g></g></svg>';
expect(node._shadowRoot.innerHTML).toBe(
`<style>${expectedBlock}</style>${blankSVG}`
);
// Check inline attribute
expect(node.inline).toBe(false);
expect(node.getAttribute('inline')).toBe(null);
// Change inline
node.inline = true;
expect(node.inline).toBe(true);
expect(node.getAttribute('inline')).toBe('true');
expect(node._shadowRoot.innerHTML).toBe(
`<style>${expectedInline}</style>${blankSVG}`
);
});
it('Restarting animation', async () => {
// Setup DOM
const doc = setupDOM('').window.document;
// Make sure component does not exist and registry is available
expect(window.customElements).toBeDefined();
expect(window.customElements.get('iconify-icon')).toBeUndefined();
// Define component
expect(defineIconifyIcon()).toBeDefined();
expect(window.customElements.get('iconify-icon')).toBeDefined();
// Create element
const node = document.createElement(
'iconify-icon'
) as DebugIconifyIconHTMLElement;
// Set icon
const body =
'<rect width="10" height="10"><animate attributeName="width" values="10;5;10" dur="10s" repeatCount="indefinite" /></rect>';
node.icon = {
body,
};
expect(node.icon).toEqual({
body,
});
expect(node.getAttribute('icon')).toBe(
JSON.stringify({
body,
})
);
// Should render SPAN, with comment
const renderedIconWithComment =
"<span style=\"--svg: url(&quot;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' preserveAspectRatio='xMidYMid meet' viewBox='0 0 16 16'%3E%3Crect width='10' height='10'%3E%3Canimate attributeName='width' values='10;5;10' dur='10s' repeatCount='indefinite' /%3E%3C/rect%3E%3C!-- --%3E%3C/svg%3E&quot;); width: 1em; height: 1em; background-color: transparent; background-repeat: no-repeat; background-size: 100% 100%;\"></span>";
const html1 = node._shadowRoot.innerHTML;
expect(html1.replace(/-- [0-9]+ --/, '-- --')).toBe(
`<style>${expectedBlock}</style>${renderedIconWithComment}`
);
// Restart animation, test icon again
node.restartAnimation();
const html2 = node._shadowRoot.innerHTML;
expect(html2).not.toBe(html1);
expect(html2.replace(/-- [0-9]+ --/, '-- --')).toBe(
`<style>${expectedBlock}</style>${renderedIconWithComment}`
);
// Small delay to make sure timer is increased to get new number
await new Promise((fulfill) => {
setTimeout(fulfill, 10);
});
// Restart animation again, test icon again
node.restartAnimation();
const html3 = node._shadowRoot.innerHTML;
expect(html3.replace(/-- [0-9]+ --/, '-- --')).toBe(
`<style>${expectedBlock}</style>${renderedIconWithComment}`
);
expect(html3).not.toBe(html1);
expect(html3).not.toBe(html2);
});
it('Restarting animation for SVG', () => {
// Setup DOM
const doc = setupDOM('').window.document;
// Make sure component does not exist and registry is available
expect(window.customElements).toBeDefined();
expect(window.customElements.get('iconify-icon')).toBeUndefined();
// Define component
expect(defineIconifyIcon()).toBeDefined();
expect(window.customElements.get('iconify-icon')).toBeDefined();
// Create element
const node = document.createElement(
'iconify-icon'
) as DebugIconifyIconHTMLElement;
// Set icon
const body =
'<rect width="10" height="10"><animate attributeName="width" values="10;5;10" dur="10s" repeatCount="indefinite" /></rect>';
node.mode = 'svg';
node.icon = {
body,
};
expect(node.icon).toEqual({
body,
});
expect(node.getAttribute('icon')).toBe(
JSON.stringify({
body,
})
);
// Should render SVG
const renderedIcon =
'<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 16 16"><rect width="10" height="10"><animate attributeName="width" values="10;5;10" dur="10s" repeatCount="indefinite"></animate></rect></svg>';
const html1 = node._shadowRoot.innerHTML;
const svg1 = node._shadowRoot.lastChild as SVGSVGElement;
const setCurrentTimeSupported = !!svg1.setCurrentTime;
expect(html1).toBe(`<style>${expectedBlock}</style>${renderedIcon}`);
expect(svg1.outerHTML).toBe(renderedIcon);
// Restart animation, test icon again
node.restartAnimation();
const html2 = node._shadowRoot.innerHTML;
const svg2 = node._shadowRoot.lastChild as SVGSVGElement;
expect(html2).toBe(html1);
expect(svg2.outerHTML).toBe(renderedIcon);
// Node should be different because JSDOM does not support setCurrentTime(), but that might change in future
if (setCurrentTimeSupported) {
expect(svg2).toBe(svg1);
} else {
expect(svg2).not.toBe(svg1);
}
});
});

View File

@ -0,0 +1,74 @@
import {
getCustomisations,
haveCustomisationsChanged,
defaultCustomisations,
} from '../src/attributes/customisations';
import { getInline } from '../src/attributes/inline';
import { cleanupGlobals, setupDOM } from './helpers';
describe('Testing customisations', () => {
afterEach(cleanupGlobals);
it('Get and compare customisations', () => {
// Setup DOM
const doc = setupDOM('').window.document;
// Create node, test default values
const node = doc.createElement('div');
const emptyCustomisations = getCustomisations(node);
expect(emptyCustomisations).toEqual(defaultCustomisations);
expect(getInline(node)).toBe(false);
expect(
haveCustomisationsChanged(
emptyCustomisations,
defaultCustomisations
)
).toBe(false);
// Test inline and height
node.innerHTML = '<span inline="true" height="1em"></span>';
let testNode = node.lastChild as HTMLSpanElement;
const test1 = getCustomisations(testNode);
expect(test1).toEqual({
...defaultCustomisations,
height: '1em',
});
expect(haveCustomisationsChanged(emptyCustomisations, test1)).toBe(
true
);
expect(getInline(testNode)).toBe(true);
// Test transformations
node.innerHTML = '<span flip="horizontal" rotate="2"></span>';
testNode = node.lastChild as HTMLSpanElement;
const test2 = getCustomisations(testNode);
expect(test2).toEqual({
...defaultCustomisations,
hFlip: true,
rotate: 2,
});
expect(haveCustomisationsChanged(emptyCustomisations, test2)).toBe(
true
);
expect(haveCustomisationsChanged(test1, test2)).toBe(true);
expect(getInline(testNode)).toBe(false);
// Dimensions and alignment. Empty value
node.innerHTML =
'<span align="left top" width="auto" height=""></span>';
testNode = node.lastChild as HTMLSpanElement;
const test3 = getCustomisations(testNode);
expect(test3).toEqual({
...defaultCustomisations,
hAlign: 'left',
vAlign: 'top',
width: 'auto',
});
expect(haveCustomisationsChanged(test3, test2)).toBe(true);
expect(haveCustomisationsChanged(test1, test3)).toBe(true);
expect(getInline(testNode)).toBe(false);
});
});

View File

@ -0,0 +1,60 @@
import { getRenderMode } from '../src/attributes/mode';
describe('Testing getRenderMode', () => {
// Default mode is 'svg'? Change this value if code in state/mode.ts changes
const defautToSVG = true;
it('Force mode', () => {
expect(getRenderMode('<g />', 'svg')).toBe('svg');
expect(getRenderMode('<g />', 'bg')).toBe('bg');
expect(getRenderMode('<g />', 'mask')).toBe('mask');
});
it('Style', () => {
expect(getRenderMode('<g />', 'style')).toBe('bg');
expect(
getRenderMode('<g><path d="" fill="currentColor" /></g>', 'style')
).toBe('mask');
});
it('Detect mode', () => {
// Icon without 'currentColor'
expect(getRenderMode('<g />', null)).toBe(defautToSVG ? 'svg' : 'bg');
expect(getRenderMode('<g />', '')).toBe(defautToSVG ? 'svg' : 'bg');
// Icon with 'currentColor'
expect(
getRenderMode('<g><path d="" fill="currentColor" /></g>', '')
).toBe(defautToSVG ? 'svg' : 'mask');
expect(
getRenderMode(
'<g><path d="" fill="currentColor" /></g>',
'whatever'
)
).toBe(defautToSVG ? 'svg' : 'mask');
});
it('Animated icons', () => {
// Animated icon without 'currentColor'
const animatedDefaultFill =
'<g><rect width="20"><animate attributeName="height" values="0;10" dur="1s" fill="freeze" /></rect></g>';
expect(getRenderMode(animatedDefaultFill, 'svg')).toBe('svg');
expect(getRenderMode(animatedDefaultFill, 'bg')).toBe('bg');
expect(getRenderMode(animatedDefaultFill, 'mask')).toBe('mask');
expect(getRenderMode(animatedDefaultFill, '')).toBe('bg');
expect(getRenderMode(animatedDefaultFill, 'style')).toBe('bg');
// Animated icon with 'currentColor'
const animatedCurrentColor =
'<g><rect width="20" fill="currentColor"><animate attributeName="height" values="0;10" dur="1s" fill="freeze" /></rect></g>';
expect(getRenderMode(animatedCurrentColor, 'svg')).toBe('svg');
expect(getRenderMode(animatedCurrentColor, 'bg')).toBe('bg');
expect(getRenderMode(animatedCurrentColor, 'mask')).toBe('mask');
expect(getRenderMode(animatedCurrentColor, '')).toBe('mask');
expect(getRenderMode(animatedCurrentColor, 'style')).toBe('mask');
});
});

View File

@ -0,0 +1,115 @@
import { JSDOM } from 'jsdom';
import { mockAPIModule, mockAPIData } from '@iconify/core/lib/api/modules/mock';
import { addAPIProvider } from '@iconify/core/lib/api/config';
import { setAPIModule } from '@iconify/core/lib/api/modules';
/**
* Generate next prefix
*/
let counter = 0;
export const nextPrefix = () => 'mock-' + counter++;
/**
* Set mock API module for provider
*/
export function fakeAPI(provider: string) {
// Set API module for provider
addAPIProvider(provider, {
resources: ['http://localhost'],
});
setAPIModule(provider, mockAPIModule);
}
export { mockAPIData };
/**
* Timeout
*
* Can chain multiple setTimeout by adding multiple 0 delays
*/
export function nextTick(delays: number[] = [0]) {
return new Promise((fulfill) => {
function next() {
if (!delays.length) {
fulfill(undefined);
return;
}
setTimeout(() => {
next();
}, delays.shift());
}
next();
});
}
/**
* Timeout, until condition is met
*/
type WaitUntilCheck = () => boolean;
export function awaitUntil(callback: WaitUntilCheck, maxDelay = 1000) {
return new Promise((fulfill, reject) => {
const start = Date.now();
function next() {
setTimeout(() => {
if (callback()) {
fulfill(undefined);
return;
}
if (Date.now() - start > maxDelay) {
reject('Timed out');
return;
}
next();
});
}
next();
});
}
/**
* Create JSDOM instance, overwrite globals
*/
export function setupDOM(html: string): JSDOM {
const dom = new JSDOM(html);
(global as unknown as Record<string, unknown>).window = dom.window;
(global as unknown as Record<string, unknown>).document =
global.window.document;
return dom;
}
/**
* Delete temporary globals
*/
export function cleanupGlobals() {
delete global.window;
delete global.document;
}
/**
* Wrap HTML
*/
export function wrapHTML(content: string): string {
return `<!doctype html>
<head>
<meta charset="utf-8">
</head>
<body>
${content}
</body>
</html>`;
}
/**
* Style tests
*/
function getStyleValue(inline: boolean): string {
return (
':host{display:inline-block;vertical-align:' +
(inline ? '-0.125em' : '0') +
'}span,svg{display:block}'
);
}
export const expectedInline = getStyleValue(true);
export const expectedBlock = getStyleValue(false);

View File

@ -0,0 +1,222 @@
import { fakeAPI, nextPrefix, mockAPIData } from './helpers';
import { iconDefaults } from '@iconify/utils/lib/icon';
import { addCollection } from '@iconify/core/lib/storage/functions';
import { parseIconValue } from '../src/attributes/icon/index';
describe('Testing parseIconValue with API', () => {
it('Loading icon from API', (done) => {
// Set config
const provider = nextPrefix();
const prefix = nextPrefix();
fakeAPI(provider);
// Mock data
const name = 'mock-test';
const iconName = `@${provider}:${prefix}:${name}`;
mockAPIData({
type: 'icons',
provider,
prefix,
response: {
prefix,
icons: {
[name]: {
body: '<g />',
},
},
},
});
// Test
let callbackCalled = false;
const result = parseIconValue(iconName, (value, icon, data) => {
expect(callbackCalled).toBe(false);
callbackCalled = true;
expect(value).toBe(iconName);
expect(icon).toEqual({
provider,
prefix,
name,
});
expect(data).toEqual({
...iconDefaults,
body: '<g />',
});
done();
});
expect(result.loading).toBeDefined();
expect(result).toEqual({
value: iconName,
name: {
provider,
prefix,
name,
},
loading: result.loading,
});
expect(callbackCalled).toBe(false);
});
it('Already exists', (done) => {
// Set config
const provider = nextPrefix();
const prefix = nextPrefix();
fakeAPI(provider);
const name = 'mock-test';
const iconName = `@${provider}:${prefix}:${name}`;
addCollection(
{
prefix,
icons: {
[name]: {
body: '<g id="test" />',
},
},
},
provider
);
// Mock data
mockAPIData({
type: 'icons',
provider,
prefix,
response: {
prefix,
icons: {
[name]: {
body: '<g />',
},
},
},
delay: () => {
done('This function should not have been called');
},
});
// Test
const result = parseIconValue(iconName, () => {
done('Callback should not have been called');
});
expect(result).toEqual({
value: iconName,
name: {
provider,
prefix,
name,
},
data: {
...iconDefaults,
body: '<g id="test" />',
},
});
done();
});
it('Failing to load', (done) => {
// Set config
const provider = nextPrefix();
const prefix = nextPrefix();
fakeAPI(provider);
// Mock data
const name = 'mock-test';
const iconName = `@${provider}:${prefix}:${name}`;
mockAPIData({
type: 'icons',
provider,
prefix,
response: {
prefix,
icons: {},
not_found: [name],
},
});
// Test
let callbackCalled = false;
const result = parseIconValue(iconName, (value, icon, data) => {
expect(callbackCalled).toBe(false);
callbackCalled = true;
expect(value).toBe(iconName);
expect(icon).toEqual({
provider,
prefix,
name,
});
expect(data).toBeFalsy();
done();
});
expect(result.loading).toBeDefined();
expect(result).toEqual({
value: iconName,
name: {
provider,
prefix,
name,
},
loading: result.loading,
});
expect(callbackCalled).toBe(false);
});
it('Already marked as missing', (done) => {
// Set config
const provider = nextPrefix();
const prefix = nextPrefix();
fakeAPI(provider);
const name = 'mock-test';
const iconName = `@${provider}:${prefix}:${name}`;
addCollection(
{
prefix,
icons: {},
not_found: [name],
},
provider
);
// Mock data
mockAPIData({
type: 'icons',
provider,
prefix,
response: {
prefix,
icons: {
[name]: {
body: '<g />',
},
},
},
delay: () => {
done('This function should not have been called');
},
});
// Test
const result = parseIconValue(iconName, () => {
done('Callback should not have been called');
});
expect(result).toEqual({
value: iconName,
name: {
provider,
prefix,
name,
},
data: null,
});
done();
});
});

View File

@ -0,0 +1,57 @@
import { parseIconValue } from '../src/attributes/icon/index';
import { iconDefaults } from '@iconify/utils/lib/icon';
describe('Testing parseIconValue without API', () => {
it('Instantly loading object', () => {
const value = {
body: '<g />',
};
const result = parseIconValue(value, () => {
throw new Error('callback should not have been called');
});
expect(result).toEqual({
value,
data: {
...iconDefaults,
...value,
},
});
expect(result.value).toBe(value);
});
it('Instantly loading serialised object', () => {
const value = JSON.stringify({
body: '<g />',
});
const result = parseIconValue(value, () => {
throw new Error('callback should not have been called');
});
expect(result).toEqual({
value,
data: {
...iconDefaults,
body: '<g />',
},
});
});
it('Bad data', () => {
const value = '<svg />';
const result = parseIconValue(value, () => {
throw new Error('callback should not have been called');
});
expect(result).toEqual({
value,
});
});
it('Icon without prefix', () => {
const value = 'test';
const result = parseIconValue(value, () => {
throw new Error('callback should not have been called');
});
expect(result).toEqual({
value,
});
});
});

View File

@ -0,0 +1,61 @@
import { testIconObject } from '../src/attributes/icon/object';
import { iconDefaults } from '@iconify/utils/lib/icon';
describe('Testing testIconObject', () => {
it('Objects', () => {
expect(
testIconObject({
body: '<g />',
})
).toEqual({
...iconDefaults,
body: '<g />',
});
expect(
testIconObject({
body: '<g />',
width: 24,
height: '32',
})
).toEqual({
...iconDefaults,
body: '<g />',
width: 24,
// Validation is simple, this will fail during render
height: '32',
});
// Invalid objects
expect(testIconObject({})).toBeUndefined();
expect(
testIconObject([
{
body: '<g />',
},
])
).toBeUndefined();
expect(
testIconObject({
body: true,
})
).toBeUndefined();
});
it('String', () => {
expect(
testIconObject(
JSON.stringify({
body: '<g />',
})
)
).toEqual({
...iconDefaults,
body: '<g />',
});
// Strings that are not objects
expect(testIconObject('foo')).toBeUndefined();
expect(testIconObject('{"body": "<g />"')).toBeUndefined();
});
});

View File

@ -0,0 +1,41 @@
import { iconExists } from '@iconify/core/lib/storage/functions';
import { loadIcon } from '@iconify/core/lib/api/icons';
import { iconDefaults } from '@iconify/utils/lib/icon';
import { fakeAPI, nextPrefix, mockAPIData } from './helpers';
describe('Testing mock API', () => {
it('Setting up API', async () => {
// Set config
const provider = nextPrefix();
const prefix = nextPrefix();
fakeAPI(provider);
// Mock data
const name = 'mock-test';
const iconName = `@${provider}:${prefix}:${name}`;
mockAPIData({
type: 'icons',
provider,
prefix,
response: {
prefix,
icons: {
[name]: {
body: '<g />',
},
},
},
});
// Check if icon has been loaded
expect(iconExists(iconName)).toBe(false);
// Load icon
const data = await loadIcon(iconName);
expect(data).toEqual({
...iconDefaults,
body: '<g />',
});
});
});

View File

@ -0,0 +1,125 @@
import { iconDefaults } from '@iconify/utils/lib/icon';
import {
cleanupGlobals,
expectedBlock,
expectedInline,
setupDOM,
} from './helpers';
import { updateStyle } from '../src/render/style';
import { renderIcon } from '../src/render/icon';
import { defaultCustomisations } from '../src/attributes/customisations';
describe('Testing rendering loaded icon', () => {
afterEach(cleanupGlobals);
it('Render as SVG', () => {
// Setup DOM
const doc = setupDOM('').window.document;
// Create container node and add style
const node = doc.createElement('div');
updateStyle(node, false);
// Render SVG
renderIcon(node, {
rendered: true,
icon: {
value: 'whatever',
data: {
...iconDefaults,
body: '<g />',
},
},
renderedMode: 'svg',
inline: false,
customisations: {
...defaultCustomisations,
},
});
// Test HTML
expect(node.innerHTML).toBe(
`<style>${expectedBlock}</style><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 16 16"><g></g></svg>`
);
// Replace icon content
renderIcon(node, {
rendered: true,
icon: {
value: 'whatever',
data: {
...iconDefaults,
width: 24,
height: 24,
body: '<g><path d="" /></g>',
},
},
renderedMode: 'svg',
inline: false,
customisations: {
...defaultCustomisations,
rotate: 1,
height: 'auto',
},
});
// Test HTML
expect(node.innerHTML).toBe(
`<style>${expectedBlock}</style><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g transform="rotate(90 12 12)"><g><path d=""></path></g></g></svg>`
);
});
it('Render as SPAN', () => {
// Setup DOM
const doc = setupDOM('').window.document;
// Create container node and add style
const node = doc.createElement('div');
updateStyle(node, true);
// Render SVG
renderIcon(node, {
rendered: true,
icon: {
value: 'whatever',
data: {
...iconDefaults,
body: '<g />',
},
},
renderedMode: 'mask',
inline: true,
customisations: {
...defaultCustomisations,
},
});
// Test HTML
expect(node.innerHTML).toBe(
`<style>${expectedInline}</style><span style="--svg: url(&quot;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' preserveAspectRatio='xMidYMid meet' viewBox='0 0 16 16'%3E%3Cg /%3E%3C/svg%3E&quot;); width: 1em; height: 1em; background-color: currentColor; mask-image: var(--svg); mask-repeat: no-repeat; mask-size: 100% 100%;"></span>`
);
// Change mode to background, add some customisations
renderIcon(node, {
rendered: true,
icon: {
value: 'whatever',
data: {
...iconDefaults,
body: '<g />',
},
},
renderedMode: 'bg',
inline: true,
customisations: {
...defaultCustomisations,
width: 24,
},
});
// Test HTML
expect(node.innerHTML).toBe(
`<style>${expectedInline}</style><span style="--svg: url(&quot;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' preserveAspectRatio='xMidYMid meet' viewBox='0 0 16 16'%3E%3Cg /%3E%3C/svg%3E&quot;); background-color: transparent; background-repeat: no-repeat; background-size: 100% 100%;"></span>`
);
});
});

View File

@ -0,0 +1,35 @@
import { updateStyle } from '../src/render/style';
import {
cleanupGlobals,
expectedBlock,
expectedInline,
setupDOM,
} from './helpers';
describe('Testing rendering style', () => {
afterEach(cleanupGlobals);
it('updateStyle', () => {
// Setup DOM
const doc = setupDOM('').window.document;
// Create container node
const node = doc.createElement('div');
// Add style to empty parent
updateStyle(node, false);
expect(node.innerHTML).toBe('<style>' + expectedBlock + '</style>');
// Change inline mode
updateStyle(node, true);
expect(node.innerHTML).toBe('<style>' + expectedInline + '</style>');
// Do not change anything
updateStyle(node, true);
expect(node.innerHTML).toBe('<style>' + expectedInline + '</style>');
// Change to block
updateStyle(node, false);
expect(node.innerHTML).toBe('<style>' + expectedBlock + '</style>');
});
});

View File

@ -0,0 +1,8 @@
{
"extends": "../tsconfig-base.json",
"compilerOptions": {
"types": ["node", "jest"],
"rootDir": ".",
"outDir": "../tests-compiled"
}
}

View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"declaration": true,
"declarationMap": false,
"sourceMap": false,
"composite": true,
"strict": false,
"moduleResolution": "node",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"importsNotUsedAsValues": "error"
}
}

View File

@ -0,0 +1,8 @@
{
"extends": "./tsconfig-base.json",
"include": ["src/**/*.ts", ".eslintrc.js"],
"compilerOptions": {
"rootDir": "./src",
"outDir": "./lib"
}
}