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:
parent
62a643cac9
commit
2365d9ff1c
@ -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,
|
||||
})
|
||||
|
165
packages/icon/tests/component-api-test.ts
Normal file
165
packages/icon/tests/component-api-test.ts
Normal 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');
|
||||
});
|
||||
});
|
@ -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("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"); 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');
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user