diff --git a/packages/icon/src/component.ts b/packages/icon/src/component.ts
index be1a8f1..964b151 100644
--- a/packages/icon/src/component.ts
+++ b/packages/icon/src/component.ts
@@ -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,
})
diff --git a/packages/icon/tests/component-api-test.ts b/packages/icon/tests/component-api-test.ts
new file mode 100644
index 0000000..50c113f
--- /dev/null
+++ b/packages/icon/tests/component-api-test.ts
@@ -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(
+ ``
+ );
+ 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: '',
+ },
+ },
+ },
+ 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(
+ ``
+ );
+ 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 =
+ '';
+ expect(node._shadowRoot.innerHTML).toBe(
+ `${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(
+ ``
+ );
+ 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(
+ ``
+ );
+ 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(
+ ``
+ );
+ expect(node.status).toBe('failed');
+ });
+});
diff --git a/packages/icon/tests/component-test.ts b/packages/icon/tests/component-test.ts
index de7cf2c..fbf46fa 100644
--- a/packages/icon/tests/component-test.ts
+++ b/packages/icon/tests/component-test.ts
@@ -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(
``
);
+ 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(
+ ``
+ );
+ expect(node.status).toBe('loading');
+ await nextTick();
+
// Should render SVG
const blankSVG =
'';
expect(node._shadowRoot.innerHTML).toBe(
`${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(
`${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 =
"";
const html1 = node._shadowRoot.innerHTML;
@@ -215,6 +240,7 @@ describe('Testing icon component', () => {
expect(html2.replace(/-- [0-9]+ --/, '-- --')).toBe(
`${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 =
'';
const html1 = node._shadowRoot.innerHTML;
@@ -288,5 +320,7 @@ describe('Testing icon component', () => {
} else {
expect(svg2).not.toBe(svg1);
}
+
+ expect(node.status).toBe('rendered');
});
});