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'); }); });