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

Web component: add status property

This commit is contained in:
Vjacheslav Trushkin 2022-05-02 09:28:48 +03:00
parent 62a643cac9
commit 2365d9ff1c
3 changed files with 305 additions and 57 deletions

View File

@ -16,12 +16,20 @@ import { renderIcon } from './render/icon';
import { updateStyle } from './render/style';
import { IconState, setPendingState } from './state';
/**
* Icon status
*/
export type IconifyIconStatus = 'rendered' | 'loading' | 'failed';
/**
* Interface
*/
declare interface PartialIconifyIconHTMLElement extends HTMLElement {
// Restart animation for animated icons
restartAnimation: () => void;
// Get status
get status(): IconifyIconStatus;
}
// Add dynamically generated getters and setters
@ -97,6 +105,9 @@ export function defineIconifyIcon(
// State
_state: IconState;
// Attributes check queued
_checkQueued = false;
/**
* Constructor
*/
@ -113,16 +124,15 @@ export function defineIconifyIcon(
updateStyle(root, inline);
// Create empty state
const value = this.getAttribute('icon');
this._state = setPendingState(
{
value,
value: '',
},
inline
);
// Update icon
this._iconChanged(value);
// Queue icon render
this._queueCheck();
}
/**
@ -135,51 +145,19 @@ export function defineIconifyIcon(
/**
* 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 = getInline(this);
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);
}
}
attributeChangedCallback(name: string) {
if (name === 'inline') {
// Update immediately: not affected by other attributes
const newInline = getInline(this);
const state = this._state;
if (newInline !== state.inline) {
// Update style if inline mode changed
state.inline = newInline;
updateStyle(this._shadowRoot, newInline);
}
} else {
// Queue check for other attributes
this._queueCheck();
}
}
@ -236,6 +214,64 @@ export function defineIconifyIcon(
}
}
/**
* Get status
*/
get status(): IconifyIconStatus {
const state = this._state;
return state.rendered
? 'rendered'
: state.icon.data === null
? 'failed'
: 'loading';
}
/**
* Queue attributes re-check
*/
_queueCheck() {
if (!this._checkQueued) {
this._checkQueued = true;
setTimeout(() => {
this._check();
});
}
}
/**
* Check for changes
*/
_check() {
if (!this._checkQueued) {
return;
}
this._checkQueued = false;
const state = this._state;
// Get icon
const newIcon = this.getAttribute('icon');
if (newIcon !== state.icon.value) {
this._iconChanged(newIcon);
return;
}
// Ignore other attributes if icon is not rendered
if (!state.rendered) {
return;
}
// Check for mode and attribute changes
const mode = this.getAttribute('mode');
const customisations = getCustomisations(this);
if (
state.attrMode !== mode ||
haveCustomisationsChanged(state.customisations, customisations)
) {
this._renderIcon(state.icon, customisations, mode);
}
}
/**
* Icon value has changed
*/
@ -243,7 +279,7 @@ export function defineIconifyIcon(
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) {
if (state.rendered || this.getAttribute('icon') !== value) {
// Icon data is already available or icon attribute was changed
return;
}
@ -257,7 +293,7 @@ export function defineIconifyIcon(
if (icon.data) {
// Render icon
this._renderIcon(icon as RenderedCurrentIconData);
this._gotIconData(icon as RenderedCurrentIconData);
} else {
// Nothing to render: update icon in state
state.icon = icon;
@ -266,7 +302,7 @@ export function defineIconifyIcon(
if (icon.data) {
// Icon is ready to render
this._renderIcon(icon as RenderedCurrentIconData);
this._gotIconData(icon as RenderedCurrentIconData);
} else {
// Pending icon
this._state = setPendingState(
@ -277,15 +313,28 @@ export function defineIconifyIcon(
}
}
/**
*
* @param icon
*/
_gotIconData(icon: RenderedCurrentIconData) {
this._checkQueued = false;
this._renderIcon(
icon,
getCustomisations(this),
this.getAttribute('mode')
);
}
/**
* Re-render based on icon data
*/
_renderIcon(
icon: RenderedCurrentIconData,
customisations?: RenderedIconCustomisations
customisations: RenderedIconCustomisations,
attrMode: string
) {
// Get mode
const attrMode = this.getAttribute('mode');
const renderedMode = getRenderMode(icon.data.body, attrMode);
// Inline was not changed
@ -298,7 +347,7 @@ export function defineIconifyIcon(
rendered: true,
icon,
inline,
customisations: customisations || getCustomisations(this),
customisations,
attrMode,
renderedMode,
})

View File

@ -0,0 +1,165 @@
import {
cleanupGlobals,
expectedBlock,
expectedInline,
setupDOM,
nextTick,
nextPrefix,
fakeAPI,
mockAPIData,
awaitUntil,
} from './helpers';
import { defineIconifyIcon, IconifyIconHTMLElement } from '../src/component';
import type { IconState } from '../src/state';
import type { IconifyMockAPIDelayDoneCallback } from '@iconify/core/lib/api/modules/mock';
export declare interface DebugIconifyIconHTMLElement
extends IconifyIconHTMLElement {
// Internal stuff, used for debugging
_shadowRoot: ShadowRoot;
_state: IconState;
}
describe('Testing icon component with API', () => {
afterEach(async () => {
await nextTick();
cleanupGlobals();
});
it('Loading icon from API', async () => {
// Setup DOM and fake API
const doc = setupDOM('').window.document;
const provider = nextPrefix();
const prefix = nextPrefix();
fakeAPI(provider);
// Define component
const IconifyIcon = defineIconifyIcon();
expect(IconifyIcon).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>`
);
expect(node.status).toBe('loading');
// Mock data
const name = 'mock-test';
const iconName = `@${provider}:${prefix}:${name}`;
let sendQuery: IconifyMockAPIDelayDoneCallback | undefined;
mockAPIData({
type: 'icons',
provider,
prefix,
response: {
prefix,
icons: {
[name]: {
body: '<g />',
},
},
},
delay: (next) => {
sendQuery = next;
},
});
// Set icon
node.setAttribute('icon', iconName);
expect(node.icon).toEqual(iconName);
expect(node.getAttribute('icon')).toBe(iconName);
// Should not have sent query to API yet
expect(sendQuery).toBeUndefined();
expect(node._shadowRoot.innerHTML).toBe(
`<style>${expectedBlock}</style>`
);
expect(node.status).toBe('loading');
// Wait until sendQuery is defined and send response
await awaitUntil(() => !!sendQuery);
sendQuery();
// Wait until icon exists
const iconExists = node.iconExists;
await awaitUntil(() => iconExists(iconName));
// Wait for render
await nextTick();
// Should render SVG
const blankSVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 16 16"><g></g></svg>';
expect(node._shadowRoot.innerHTML).toBe(
`<style>${expectedBlock}</style>${blankSVG}`
);
expect(node.status).toBe('rendered');
});
it('Failing to load from API', async () => {
// Setup DOM and fake API
const doc = setupDOM('').window.document;
const provider = nextPrefix();
const prefix = nextPrefix();
fakeAPI(provider);
// Define component
const IconifyIcon = defineIconifyIcon();
expect(IconifyIcon).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>`
);
expect(node.status).toBe('loading');
// Mock data
const name = 'mock-test';
const iconName = `@${provider}:${prefix}:${name}`;
mockAPIData({
type: 'icons',
provider,
prefix,
response: {
prefix,
icons: {},
not_found: [name],
},
});
// Set icon
node.setAttribute('icon', iconName);
expect(node.icon).toEqual(iconName);
expect(node.getAttribute('icon')).toBe(iconName);
// Should not have sent query to API yet
expect(node._shadowRoot.innerHTML).toBe(
`<style>${expectedBlock}</style>`
);
expect(node.status).toBe('loading');
// Wait until status changes
expect(node.status).toBe('loading');
await awaitUntil(() => node.status !== 'loading');
// Should fail to render
expect(node._shadowRoot.innerHTML).toBe(
`<style>${expectedBlock}</style>`
);
expect(node.status).toBe('failed');
});
});

View File

@ -3,6 +3,7 @@ import {
expectedBlock,
expectedInline,
setupDOM,
nextTick,
} from './helpers';
import { defineIconifyIcon, IconifyIconHTMLElement } from '../src/component';
import type { IconState } from '../src/state';
@ -15,7 +16,10 @@ export declare interface DebugIconifyIconHTMLElement
}
describe('Testing icon component', () => {
afterEach(cleanupGlobals);
afterEach(async () => {
await nextTick();
cleanupGlobals();
});
it('Registering component', () => {
// Setup DOM
@ -35,6 +39,7 @@ describe('Testing icon component', () => {
'iconify-icon'
) as DebugIconifyIconHTMLElement;
expect(node instanceof IconifyIcon).toBe(true);
expect(node.status).toBe('loading');
// Define component again (should return previous class)
const IconifyIcon2 = defineIconifyIcon();
@ -45,9 +50,10 @@ describe('Testing icon component', () => {
'iconify-icon'
) as DebugIconifyIconHTMLElement;
expect(node2 instanceof IconifyIcon).toBe(true);
expect(node2.status).toBe('loading');
});
it('Creating component instance, changing properties', () => {
it('Creating component instance, changing properties', async () => {
// Setup DOM
const doc = setupDOM('').window.document;
@ -69,6 +75,7 @@ describe('Testing icon component', () => {
expect(node._shadowRoot.innerHTML).toBe(
`<style>${expectedBlock}</style>`
);
expect(node.status).toBe('loading');
// Check for dynamically added methods
expect(typeof node.loadIcon).toBe('function');
@ -87,12 +94,20 @@ describe('Testing icon component', () => {
})
);
// Should still be empty: waiting for next tick
expect(node._shadowRoot.innerHTML).toBe(
`<style>${expectedBlock}</style>`
);
expect(node.status).toBe('loading');
await nextTick();
// Should render SVG
const blankSVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 16 16"><g></g></svg>';
expect(node._shadowRoot.innerHTML).toBe(
`<style>${expectedBlock}</style>${blankSVG}`
);
expect(node.status).toBe('rendered');
// Check inline attribute
expect(node.inline).toBe(false);
@ -106,6 +121,7 @@ describe('Testing icon component', () => {
expect(node._shadowRoot.innerHTML).toBe(
`<style>${expectedInline}</style>${blankSVG}`
);
expect(node.status).toBe('rendered');
});
it('Testing changes to inline', () => {
@ -125,6 +141,7 @@ describe('Testing icon component', () => {
const node = document.createElement(
'iconify-icon'
) as DebugIconifyIconHTMLElement;
expect(node.status).toBe('loading');
// Should be empty with block style
expect(node._shadowRoot.innerHTML).toBe(
@ -165,6 +182,9 @@ describe('Testing icon component', () => {
expect(node.inline).toBe(true);
expect(node.hasAttribute('inline')).toBe(true);
expect(node.getAttribute('inline')).toBeTruthy();
// No icon data, so still loading
expect(node.status).toBe('loading');
});
it('Restarting animation', async () => {
@ -183,6 +203,7 @@ describe('Testing icon component', () => {
const node = document.createElement(
'iconify-icon'
) as DebugIconifyIconHTMLElement;
expect(node.status).toBe('loading');
// Set icon
const body =
@ -199,7 +220,11 @@ describe('Testing icon component', () => {
})
);
// Wait to render
await nextTick();
// Should render SPAN, with comment
expect(node.status).toBe('rendered');
const renderedIconWithComment =
"<span style=\"--svg: url(&quot;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' 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;
@ -215,6 +240,7 @@ describe('Testing icon component', () => {
expect(html2.replace(/-- [0-9]+ --/, '-- --')).toBe(
`<style>${expectedBlock}</style>${renderedIconWithComment}`
);
expect(node.status).toBe('rendered');
// Small delay to make sure timer is increased to get new number
await new Promise((fulfill) => {
@ -230,9 +256,10 @@ describe('Testing icon component', () => {
);
expect(html3).not.toBe(html1);
expect(html3).not.toBe(html2);
expect(node.status).toBe('rendered');
});
it('Restarting animation for SVG', () => {
it('Restarting animation for SVG', async () => {
// Setup DOM
const doc = setupDOM('').window.document;
@ -248,6 +275,7 @@ describe('Testing icon component', () => {
const node = document.createElement(
'iconify-icon'
) as DebugIconifyIconHTMLElement;
expect(node.status).toBe('loading');
// Set icon
const body =
@ -265,7 +293,11 @@ describe('Testing icon component', () => {
})
);
// Wait to render
await nextTick();
// Should render SVG
expect(node.status).toBe('rendered');
const renderedIcon =
'<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" 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;
@ -288,5 +320,7 @@ describe('Testing icon component', () => {
} else {
expect(svg2).not.toBe(svg1);
}
expect(node.status).toBe('rendered');
});
});