2
0
mirror of https://github.com/iconify/iconify.git synced 2025-01-07 15:44:05 +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 { updateStyle } from './render/style';
import { IconState, setPendingState } from './state'; import { IconState, setPendingState } from './state';
/**
* Icon status
*/
export type IconifyIconStatus = 'rendered' | 'loading' | 'failed';
/** /**
* Interface * Interface
*/ */
declare interface PartialIconifyIconHTMLElement extends HTMLElement { declare interface PartialIconifyIconHTMLElement extends HTMLElement {
// Restart animation for animated icons // Restart animation for animated icons
restartAnimation: () => void; restartAnimation: () => void;
// Get status
get status(): IconifyIconStatus;
} }
// Add dynamically generated getters and setters // Add dynamically generated getters and setters
@ -97,6 +105,9 @@ export function defineIconifyIcon(
// State // State
_state: IconState; _state: IconState;
// Attributes check queued
_checkQueued = false;
/** /**
* Constructor * Constructor
*/ */
@ -113,16 +124,15 @@ export function defineIconifyIcon(
updateStyle(root, inline); updateStyle(root, inline);
// Create empty state // Create empty state
const value = this.getAttribute('icon');
this._state = setPendingState( this._state = setPendingState(
{ {
value, value: '',
}, },
inline inline
); );
// Update icon // Queue icon render
this._iconChanged(value); this._queueCheck();
} }
/** /**
@ -135,51 +145,19 @@ export function defineIconifyIcon(
/** /**
* Attribute has changed * Attribute has changed
*/ */
attributeChangedCallback( attributeChangedCallback(name: string) {
name: string, if (name === 'inline') {
oldValue: unknown, // Update immediately: not affected by other attributes
newValue: unknown const newInline = getInline(this);
) { const state = this._state;
const state = this._state; if (newInline !== state.inline) {
switch (name as keyof IconifyIconAttributes) { // Update style if inline mode changed
case 'icon': { state.inline = newInline;
this._iconChanged(newValue); updateStyle(this._shadowRoot, newInline);
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);
}
}
} }
} 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 * Icon value has changed
*/ */
@ -243,7 +279,7 @@ export function defineIconifyIcon(
const icon = parseIconValue(newValue, (value, name, data) => { const icon = parseIconValue(newValue, (value, name, data) => {
// Asynchronous callback: re-check values to make sure stuff wasn't changed // Asynchronous callback: re-check values to make sure stuff wasn't changed
const state = this._state; 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 // Icon data is already available or icon attribute was changed
return; return;
} }
@ -257,7 +293,7 @@ export function defineIconifyIcon(
if (icon.data) { if (icon.data) {
// Render icon // Render icon
this._renderIcon(icon as RenderedCurrentIconData); this._gotIconData(icon as RenderedCurrentIconData);
} else { } else {
// Nothing to render: update icon in state // Nothing to render: update icon in state
state.icon = icon; state.icon = icon;
@ -266,7 +302,7 @@ export function defineIconifyIcon(
if (icon.data) { if (icon.data) {
// Icon is ready to render // Icon is ready to render
this._renderIcon(icon as RenderedCurrentIconData); this._gotIconData(icon as RenderedCurrentIconData);
} else { } else {
// Pending icon // Pending icon
this._state = setPendingState( 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 * Re-render based on icon data
*/ */
_renderIcon( _renderIcon(
icon: RenderedCurrentIconData, icon: RenderedCurrentIconData,
customisations?: RenderedIconCustomisations customisations: RenderedIconCustomisations,
attrMode: string
) { ) {
// Get mode // Get mode
const attrMode = this.getAttribute('mode');
const renderedMode = getRenderMode(icon.data.body, attrMode); const renderedMode = getRenderMode(icon.data.body, attrMode);
// Inline was not changed // Inline was not changed
@ -298,7 +347,7 @@ export function defineIconifyIcon(
rendered: true, rendered: true,
icon, icon,
inline, inline,
customisations: customisations || getCustomisations(this), customisations,
attrMode, attrMode,
renderedMode, 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, expectedBlock,
expectedInline, expectedInline,
setupDOM, setupDOM,
nextTick,
} from './helpers'; } from './helpers';
import { defineIconifyIcon, IconifyIconHTMLElement } from '../src/component'; import { defineIconifyIcon, IconifyIconHTMLElement } from '../src/component';
import type { IconState } from '../src/state'; import type { IconState } from '../src/state';
@ -15,7 +16,10 @@ export declare interface DebugIconifyIconHTMLElement
} }
describe('Testing icon component', () => { describe('Testing icon component', () => {
afterEach(cleanupGlobals); afterEach(async () => {
await nextTick();
cleanupGlobals();
});
it('Registering component', () => { it('Registering component', () => {
// Setup DOM // Setup DOM
@ -35,6 +39,7 @@ describe('Testing icon component', () => {
'iconify-icon' 'iconify-icon'
) as DebugIconifyIconHTMLElement; ) as DebugIconifyIconHTMLElement;
expect(node instanceof IconifyIcon).toBe(true); expect(node instanceof IconifyIcon).toBe(true);
expect(node.status).toBe('loading');
// Define component again (should return previous class) // Define component again (should return previous class)
const IconifyIcon2 = defineIconifyIcon(); const IconifyIcon2 = defineIconifyIcon();
@ -45,9 +50,10 @@ describe('Testing icon component', () => {
'iconify-icon' 'iconify-icon'
) as DebugIconifyIconHTMLElement; ) as DebugIconifyIconHTMLElement;
expect(node2 instanceof IconifyIcon).toBe(true); 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 // Setup DOM
const doc = setupDOM('').window.document; const doc = setupDOM('').window.document;
@ -69,6 +75,7 @@ describe('Testing icon component', () => {
expect(node._shadowRoot.innerHTML).toBe( expect(node._shadowRoot.innerHTML).toBe(
`<style>${expectedBlock}</style>` `<style>${expectedBlock}</style>`
); );
expect(node.status).toBe('loading');
// Check for dynamically added methods // Check for dynamically added methods
expect(typeof node.loadIcon).toBe('function'); 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 // Should render SVG
const blankSVG = const blankSVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 16 16"><g></g></svg>'; '<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( expect(node._shadowRoot.innerHTML).toBe(
`<style>${expectedBlock}</style>${blankSVG}` `<style>${expectedBlock}</style>${blankSVG}`
); );
expect(node.status).toBe('rendered');
// Check inline attribute // Check inline attribute
expect(node.inline).toBe(false); expect(node.inline).toBe(false);
@ -106,6 +121,7 @@ describe('Testing icon component', () => {
expect(node._shadowRoot.innerHTML).toBe( expect(node._shadowRoot.innerHTML).toBe(
`<style>${expectedInline}</style>${blankSVG}` `<style>${expectedInline}</style>${blankSVG}`
); );
expect(node.status).toBe('rendered');
}); });
it('Testing changes to inline', () => { it('Testing changes to inline', () => {
@ -125,6 +141,7 @@ describe('Testing icon component', () => {
const node = document.createElement( const node = document.createElement(
'iconify-icon' 'iconify-icon'
) as DebugIconifyIconHTMLElement; ) as DebugIconifyIconHTMLElement;
expect(node.status).toBe('loading');
// Should be empty with block style // Should be empty with block style
expect(node._shadowRoot.innerHTML).toBe( expect(node._shadowRoot.innerHTML).toBe(
@ -165,6 +182,9 @@ describe('Testing icon component', () => {
expect(node.inline).toBe(true); expect(node.inline).toBe(true);
expect(node.hasAttribute('inline')).toBe(true); expect(node.hasAttribute('inline')).toBe(true);
expect(node.getAttribute('inline')).toBeTruthy(); expect(node.getAttribute('inline')).toBeTruthy();
// No icon data, so still loading
expect(node.status).toBe('loading');
}); });
it('Restarting animation', async () => { it('Restarting animation', async () => {
@ -183,6 +203,7 @@ describe('Testing icon component', () => {
const node = document.createElement( const node = document.createElement(
'iconify-icon' 'iconify-icon'
) as DebugIconifyIconHTMLElement; ) as DebugIconifyIconHTMLElement;
expect(node.status).toBe('loading');
// Set icon // Set icon
const body = const body =
@ -199,7 +220,11 @@ describe('Testing icon component', () => {
}) })
); );
// Wait to render
await nextTick();
// Should render SPAN, with comment // Should render SPAN, with comment
expect(node.status).toBe('rendered');
const renderedIconWithComment = 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>"; "<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; const html1 = node._shadowRoot.innerHTML;
@ -215,6 +240,7 @@ describe('Testing icon component', () => {
expect(html2.replace(/-- [0-9]+ --/, '-- --')).toBe( expect(html2.replace(/-- [0-9]+ --/, '-- --')).toBe(
`<style>${expectedBlock}</style>${renderedIconWithComment}` `<style>${expectedBlock}</style>${renderedIconWithComment}`
); );
expect(node.status).toBe('rendered');
// Small delay to make sure timer is increased to get new number // Small delay to make sure timer is increased to get new number
await new Promise((fulfill) => { await new Promise((fulfill) => {
@ -230,9 +256,10 @@ describe('Testing icon component', () => {
); );
expect(html3).not.toBe(html1); expect(html3).not.toBe(html1);
expect(html3).not.toBe(html2); expect(html3).not.toBe(html2);
expect(node.status).toBe('rendered');
}); });
it('Restarting animation for SVG', () => { it('Restarting animation for SVG', async () => {
// Setup DOM // Setup DOM
const doc = setupDOM('').window.document; const doc = setupDOM('').window.document;
@ -248,6 +275,7 @@ describe('Testing icon component', () => {
const node = document.createElement( const node = document.createElement(
'iconify-icon' 'iconify-icon'
) as DebugIconifyIconHTMLElement; ) as DebugIconifyIconHTMLElement;
expect(node.status).toBe('loading');
// Set icon // Set icon
const body = const body =
@ -265,7 +293,11 @@ describe('Testing icon component', () => {
}) })
); );
// Wait to render
await nextTick();
// Should render SVG // Should render SVG
expect(node.status).toBe('rendered');
const renderedIcon = 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>'; '<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; const html1 = node._shadowRoot.innerHTML;
@ -288,5 +320,7 @@ describe('Testing icon component', () => {
} else { } else {
expect(svg2).not.toBe(svg1); expect(svg2).not.toBe(svg1);
} }
expect(node.status).toBe('rendered');
}); });
}); });