diff --git a/documents/src/pages/elements/icon.md b/documents/src/pages/elements/icon.md index e60cf1ae2d..5edf621a2f 100755 --- a/documents/src/pages/elements/icon.md +++ b/documents/src/pages/elements/icon.md @@ -64,26 +64,6 @@ The size and color of an icon can be changed using standard CSS styling. ``` -## Icon preloading -`ef-icon` has a helper function to preload a set of icons. Icons can be loaded faster if you have a known set of icons for use in the app. - -Preloading icons will be deferred until the first `ef-icon` component is created. - -```javascript -import { preload } from '@refinitiv-ui/elements/icon'; - -// preload function supports both icon name or svg location, either single icon or multiple. -preload('eye'); -preload('https://cdn.io/eye.svg'); -preload('eye', 'heart', 'like', 'arrow-up'); -preload( - 'https://cdn.io/eye.svg', - 'https://cdn.io/heart.svg', - 'https://cdn.io/like.svg', - 'https://cdn.io/arrow-up.svg' -); -``` - ## Accessibility ::a11y-intro:: diff --git a/packages/elemental-theme/src/custom-elements/ef-icon.less b/packages/elemental-theme/src/custom-elements/ef-icon.less index 722fcf756e..376ffe4f4b 100644 --- a/packages/elemental-theme/src/custom-elements/ef-icon.less +++ b/packages/elemental-theme/src/custom-elements/ef-icon.less @@ -1,5 +1,5 @@ :host { - --cdn-prefix: 'https://cdn.refinitiv.net/public/libs/elf/assets/elf-theme-halo/resources/icons/'; + --cdn-sprite-prefix: 'https://cdn.refinitiv.net/public/libs/elf/assets/elf-theme-halo/resources/sprites/icons.svg'; // to make :active work on IE11 svg { diff --git a/packages/elements/src/collapse/index.ts b/packages/elements/src/collapse/index.ts index f172751e87..789ab6cafd 100644 --- a/packages/elements/src/collapse/index.ts +++ b/packages/elements/src/collapse/index.ts @@ -14,14 +14,11 @@ import { state } from '@refinitiv-ui/core/decorators/state.js'; import { Ref, createRef, ref } from '@refinitiv-ui/core/directives/ref.js'; import '../header/index.js'; -import { preload } from '../icon/index.js'; import '../icon/index.js'; import '../panel/index.js'; import type { Panel } from '../panel/index.js'; import { VERSION } from '../version.js'; -preload('right'); /* preload calendar icons for faster loading */ - /** * Allows users to hide non-critical information * or areas of the screen, maximizing the amount of real estate diff --git a/packages/elements/src/datetime-picker/index.ts b/packages/elements/src/datetime-picker/index.ts index a11bb93967..09d52a9230 100644 --- a/packages/elements/src/datetime-picker/index.ts +++ b/packages/elements/src/datetime-picker/index.ts @@ -39,7 +39,6 @@ import '../calendar/index.js'; import type { OpenedChangedEvent, ValueChangedEvent, ViewChangedEvent } from '../events'; import type { Icon } from '../icon'; import '../icon/index.js'; -import { preload } from '../icon/index.js'; import type { Overlay } from '../overlay'; import '../overlay/index.js'; import type { TextField } from '../text-field'; @@ -65,8 +64,6 @@ import { getDateFNSLocale } from './locales.js'; import type { DatetimePickerDuplex, DatetimePickerFilter } from './types'; import { DateTimeSegment, formatToView, getCurrentTime } from './utils.js'; -preload('calendar', 'down', 'left', 'right'); /* preload calendar icons for faster loading */ - export type { DatetimePickerDuplex, DatetimePickerFilter }; /** diff --git a/packages/elements/src/flag/__test__/flag.test.js b/packages/elements/src/flag/__test__/flag.test.js index b58f39f9b9..c2d98166cd 100644 --- a/packages/elements/src/flag/__test__/flag.test.js +++ b/packages/elements/src/flag/__test__/flag.test.js @@ -5,18 +5,29 @@ import '@refinitiv-ui/elements/flag'; import { preload } from '@refinitiv-ui/elements/flag'; import '@refinitiv-ui/halo-theme/light/ef-flag.js'; -import { elementUpdated, expect, isIE } from '@refinitiv-ui/test-helpers'; +import { elementUpdated, expect } from '@refinitiv-ui/test-helpers'; import { checkRequestedUrl, createAndWaitForLoad, + createFakeResponse, createMockSrc, flagName, gbSvg, - generateUniqueName + generateUniqueName, + isEqualSvg, + responseConfigError, + responseConfigSuccess } from './helpers/helpers.js'; describe('flag/Flag', function () { + let fetch; + beforeEach(function () { + fetch = sinon.stub(window, 'fetch'); + }); + afterEach(function () { + window.fetch.restore(); // remove stub + }); describe('Should Have Correct DOM Structure', function () { it('without flag or src attributes', async function () { const el = await createAndWaitForLoad(''); @@ -25,33 +36,26 @@ describe('flag/Flag', function () { }); it('with valid flag attribute', async function () { - const server = sinon.createFakeServer({ respondImmediately: true }); - server.respondWith([200, { 'Content-Type': 'image/svg+xml' }, gbSvg]); + createFakeResponse(gbSvg, responseConfigSuccess); const el = await createAndWaitForLoad(``); const svg = el.shadowRoot.querySelector('svg'); expect(svg).to.not.equal(null, 'SVG element should exist for valid flag attribute'); // Unable to make snapshots of SVGs because of semantic-dom-dif: https://open-wc.org/testing/semantic-dom-diff.html // Avoiding this check on IE because it adds custom attributes which cant be ignored with `ignoreAttributes` - if (!isIE) { - expect(svg.outerHTML).to.equal(gbSvg, 'Should render SVG, from the server response'); - } + expect(svg.outerHTML).to.equal(gbSvg, 'Should render SVG, from the server response'); }); it('with valid src attribute', async function () { - const server = sinon.createFakeServer({ respondImmediately: true }); - server.respondWith([200, { 'Content-Type': 'image/svg+xml' }, gbSvg]); + createFakeResponse(gbSvg, responseConfigSuccess); const el = await createAndWaitForLoad(''); const svg = el.shadowRoot.querySelector('svg'); expect(svg).to.not.equal(null, 'SVG element should exist for valid src attribute'); - if (!isIE) { - expect(svg.outerHTML).to.equal(gbSvg, 'Should render SVG, from the server response'); - } + expect(svg.outerHTML).to.equal(gbSvg, 'Should render SVG, from the server response'); }); it('with invalid flag attribute', async function () { - const server = sinon.createFakeServer({ respondImmediately: true }); - server.respondWith([404, {}, '']); + createFakeResponse('', responseConfigError); const el = await createAndWaitForLoad(''); const svg = el.shadowRoot.querySelector('svg'); @@ -59,8 +63,7 @@ describe('flag/Flag', function () { }); it('with invalid src attribute', async function () { - const server = sinon.createFakeServer({ respondImmediately: true }); - server.respondWith([404, {}, '']); + createFakeResponse('', responseConfigError); const el = await createAndWaitForLoad( '' ); @@ -70,8 +73,7 @@ describe('flag/Flag', function () { }); it('with empty flag attribute', async function () { - const server = sinon.createFakeServer({ respondImmediately: true }); - server.respondWith([404, {}, '']); + createFakeResponse('', responseConfigError); const el = await createAndWaitForLoad(''); const svg = el.shadowRoot.querySelector('svg'); @@ -79,17 +81,30 @@ describe('flag/Flag', function () { }); it('with empty src attribute', async function () { - const server = sinon.createFakeServer({ respondImmediately: true }); - server.respondWith([404, {}, '']); + createFakeResponse('', responseConfigError); const el = await createAndWaitForLoad(''); const svg = el.shadowRoot.querySelector('svg'); expect(svg).to.equal(null, 'SVG element should not exist for empty src attribute'); }); + it('With valid flag attribute to invalid one', async function () { + createFakeResponse(gbSvg, responseConfigSuccess); + const el = await createAndWaitForLoad(``); + let svg = el.shadowRoot.querySelector('svg'); + + expect(svg).to.not.equal(null, 'SVG element should exist for valid flag attribute'); + expect(isEqualSvg(svg.outerHTML, gbSvg)).to.equal(true, 'Should render SVG, from the server response'); + + el.setAttribute('flag', 'invalid'); + await elementUpdated(el); + svg = el.shadowRoot.querySelector('svg'); + + expect(svg).to.equal(null, 'SVG element should not exist for invalid flag attribute'); + }); + it('with unsafe nodes in response', async function () { - const server = sinon.createFakeServer({ respondImmediately: true }); - server.respondWith([200, { 'Content-Type': 'image/svg+xml' }, '']); + createFakeResponse('', responseConfigSuccess); const el = await createAndWaitForLoad(''); const script = el.shadowRoot.querySelector('script'); @@ -99,24 +114,32 @@ describe('flag/Flag', function () { describe('Should have correct attributes and properties', function () { describe('flag attribute/property', function () { - let server; + // let server; let el; + // let fetch; - before(function () { - server = sinon.createFakeServer({ respondImmediately: true }); - server.respondWith([200, { 'Content-Type': 'image/svg+xml' }, gbSvg]); - }); + // before(function () { + // server = sinon.createFakeServer({ respondImmediately: true }); + // server.respondWith([200, { 'Content-Type': 'image/svg+xml' }, gbSvg]); + // }); - beforeEach(async function () { + before(async function () { el = await createAndWaitForLoad(''); + // fetch = sinon.stub(window, 'fetch'); + // createFakeResponse(gbSvg, responseConfigSuccess); }); + // afterEach(function () { + // window.fetch.restore(); // remove stub + // }); it('should not have flag attribute by default', function () { + createFakeResponse(gbSvg, responseConfigSuccess); expect(el.hasAttribute('flag')).to.equal(false, 'Flag should not have the flag attribute by default'); expect(el.flag).to.equal(null, 'Flag should not have the flag property by default'); }); it('should have flag attribute when set', async function () { + createFakeResponse(gbSvg, responseConfigSuccess); el.setAttribute('flag', flagName); await elementUpdated(el); @@ -129,6 +152,7 @@ describe('flag/Flag', function () { }); it('should have flag attribute reflected when flag property is set', async function () { + createFakeResponse(gbSvg, responseConfigSuccess); el.flag = flagName; await elementUpdated(el); @@ -142,17 +166,12 @@ describe('flag/Flag', function () { describe('src attribute/property', function () { let srcValue; - let server; let el; - before(function () { - srcValue = createMockSrc(flagName); - server = sinon.createFakeServer({ respondImmediately: true }); - server.respondWith([200, { 'Content-Type': 'image/svg+xml' }, gbSvg]); - }); - beforeEach(async function () { + srcValue = createMockSrc(flagName); el = await createAndWaitForLoad(''); + createFakeResponse(gbSvg, responseConfigSuccess); }); it('should not have src attribute by default', function () { @@ -187,8 +206,6 @@ describe('flag/Flag', function () { describe('Functional Tests', function () { it('should set the src property based on the flag and CDN prefix', async function () { - const server = sinon.createFakeServer({ respondImmediately: true }); - server.respondWith([200, { 'Content-Type': 'image/svg+xml' }, gbSvg]); const el = await createAndWaitForLoad(``); const CDNPrefix = el.getComputedVariable('--cdn-prefix'); @@ -207,8 +224,7 @@ describe('flag/Flag', function () { }); it('should make a correct server request based on cdn prefix and the flag if flag is specified', async function () { - const server = sinon.createFakeServer({ respondImmediately: true }); - server.respondWith([200, { 'Content-Type': 'image/svg+xml' }, gbSvg]); + createFakeResponse(gbSvg, responseConfigSuccess); const uniqueFlagName = generateUniqueName(flagName); // to avoid caching const el = await createAndWaitForLoad(``); const CDNPrefix = el.getComputedVariable('--cdn-prefix'); @@ -216,33 +232,30 @@ describe('flag/Flag', function () { expect(CDNPrefix, 'CDN prefix should exist to create the src based on the flag').to.exist; const expectedSrc = `${CDNPrefix}${uniqueFlagName}.svg`; - expect(server.requests.length).to.equal(1, 'Should make one request'); - expect(server.requests[0].url).to.equal( - expectedSrc, - `requested URL should be ${expectedSrc} for the flag ${uniqueFlagName}` + expect(fetch.callCount).to.equal(1, 'Should make one request'); + expect(checkRequestedUrl(fetch.args, expectedSrc)).to.equal( + true, + `Requested URL should be ${expectedSrc} for the flag ${uniqueFlagName}` ); }); it('should make a correct server request based on src', async function () { - const server = sinon.createFakeServer({ respondImmediately: true }); + createFakeResponse(gbSvg, responseConfigSuccess); const uniqueFlagName = generateUniqueName(flagName); // to avoid caching const uniqueSrc = createMockSrc(uniqueFlagName); - server.respondWith('GET', uniqueSrc, [200, { 'Content-Type': 'image/svg+xml' }, gbSvg]); await createAndWaitForLoad(``); - expect(server.requests.length).to.equal(1, 'Should make one request'); - expect(server.requests[0].url).to.equal(uniqueSrc, `requested URL should be ${uniqueSrc}`); + expect(fetch.callCount).to.equal(1, 'Should make one request'); + expect(checkRequestedUrl(fetch.args, uniqueSrc)).to.equal(true, `Requested URL should be ${uniqueSrc}`); }); it('should preload flags', async function () { - let server = sinon.createFakeServer({ respondImmediately: true }); - const el = await createAndWaitForLoad(''); const CDNPrefix = el.getComputedVariable('--cdn-prefix'); expect(CDNPrefix, 'CDN prefix should exist in order for preload to work properly with flag name').to .exist; - expect(server.requests.length).to.equal(0, 'No request should be sent for empty flag'); + expect(fetch.callCount).to.equal(0, 'No request should be sent for empty flag'); const firstUniqueFlag = generateUniqueName(flagName); const secondUniqueFlag = generateUniqueName(flagName); @@ -252,26 +265,29 @@ describe('flag/Flag', function () { const secondUniqueFlagSrc = createMockSrc(secondUniqueFlag); const uniqueInvalidFlagSrc = `${CDNPrefix}${uniqueInvalidFlag}.svg`; - server.respondWith('GET', firstUniqueFlagSrc, [200, { 'Content-Type': 'image/svg+xml' }, gbSvg]); - server.respondWith('GET', secondUniqueFlagSrc, [200, { 'Content-Type': 'image/svg+xml' }, gbSvg]); - server.respondWith('GET', uniqueInvalidFlagSrc, [404, {}, '']); - - const preloadedFlags = await Promise.all( - preload(firstUniqueFlag, secondUniqueFlagSrc, uniqueInvalidFlag) + fetch.withArgs(firstUniqueFlagSrc).resolves( + new Response(gbSvg, { + status: 200, + headers: { 'Content-Type': 'image/svg+xml' } + }) ); - expect(server.requests.length).to.equal(3, 'Server requests for all preloaded flags should be made'); - expect(checkRequestedUrl(server.requests, firstUniqueFlagSrc)).to.equal( - true, - 'should request flags by name with CDN prefix' + fetch.withArgs(secondUniqueFlagSrc).resolves( + new Response(gbSvg, { + status: 200, + headers: { 'Content-Type': 'image/svg+xml' } + }) ); - expect(checkRequestedUrl(server.requests, secondUniqueFlagSrc)).to.equal( - true, - 'should request flags with src' + fetch.withArgs(uniqueInvalidFlagSrc).resolves( + new Response('', { + status: 404, + headers: {} + }) ); - expect(checkRequestedUrl(server.requests, uniqueInvalidFlagSrc)).to.equal( - true, - 'should try to request invalid flag' + + const preloadedFlags = await Promise.all( + preload(firstUniqueFlag, secondUniqueFlagSrc, uniqueInvalidFlag) ); + expect(fetch.callCount).to.equal(3, 'Server requests for all preloaded flags should be made'); expect(preloadedFlags[0].length > 0).to.equal( true, 'Should successfully preload flag by name with CDN prefix' @@ -281,10 +297,7 @@ describe('flag/Flag', function () { el.setAttribute('flag', firstUniqueFlag); await elementUpdated(el); - expect(server.requests.length).to.equal( - 3, - 'no new requests should be made since flags are already preloaded' - ); + expect(fetch.callCount).to.equal(3, 'no new requests should be made since flags are already preloaded'); }); }); }); diff --git a/packages/elements/src/flag/__test__/helpers/helpers.js b/packages/elements/src/flag/__test__/helpers/helpers.js index ffaac6a6fe..306f4b457f 100644 --- a/packages/elements/src/flag/__test__/helpers/helpers.js +++ b/packages/elements/src/flag/__test__/helpers/helpers.js @@ -7,13 +7,13 @@ let flagId = 0; export const createAndWaitForLoad = async (template) => { const el = await fixture(template); - await nextFrame(); + await nextFrame(5); return el; }; export const checkRequestedUrl = (requests, url) => { for (let i = 0; i < requests.length; i++) { - if (requests[i].url === url) { + if (requests[i][0] === url) { return true; } } @@ -23,3 +23,39 @@ export const checkRequestedUrl = (requests, url) => { export const generateUniqueName = (name) => `${name}_${(flagId += 1)}`; export const createMockSrc = (flag) => `https://mock.cdn.com/flags/${flag}.svg`; + +export const createFakeResponse = (body, config = responseConfigSuccess) => { + const { ok, status, headers } = config; + const response = new window.Response(body, { + ok, + status, + headers, + clone: () => ({ + text: async () => { + return await Promise.resolve(body); + } + }) + }); + window.fetch.returns(Promise.resolve(response)); +}; + +export const responseConfigSuccess = { + ok: true, + status: 200, + headers: { + 'Content-type': 'image/svg+xml' + } +}; + +export const responseConfigError = { + ok: false, + status: 404, + headers: {} +}; + +export const isEqualSvg = (svg, otherSvg) => { + const parser = new DOMParser(); + const svgNode = parser.parseFromString(svg, 'image/svg+xml'); + const otherSvgNode = parser.parseFromString(otherSvg, 'image/svg+xml'); + return svgNode.isEqualNode(otherSvgNode); +}; diff --git a/packages/elements/src/flag/index.ts b/packages/elements/src/flag/index.ts index 90dfbd9503..5c814bbbee 100644 --- a/packages/elements/src/flag/index.ts +++ b/packages/elements/src/flag/index.ts @@ -76,7 +76,7 @@ export class Flag extends BasicElement { * when deprecated features are used. */ private deprecationNotice = new DeprecationNotice( - '`src` attribute and property are deprecated. Use `flag` for attribute and property instead.' + '`src` attribute and property are deprecated. Use `flag` attribute and property instead.' ); private _src: string | null = null; diff --git a/packages/elements/src/icon/__demo__/index.html b/packages/elements/src/icon/__demo__/index.html index e70ca51437..8ff3f98fef 100644 --- a/packages/elements/src/icon/__demo__/index.html +++ b/packages/elements/src/icon/__demo__/index.html @@ -9,7 +9,6 @@ ']); + createFakeResponse('', responseConfigSuccess); const el = await createAndWaitForLoad(''); const script = el.shadowRoot.querySelector('script'); expect(script).to.equal(null, 'should strip unsafe nodes'); }); + + it('With valid icon attribute to invalid one', async function () { + createFakeResponse(tickSvgSprite, responseConfigSuccess); + const el = await createAndWaitForLoad(``); + let svg = el.shadowRoot.querySelector('svg'); + + expect(svg).to.not.equal(null, 'SVG element should exist for valid icon attribute'); + expect(isEqualSvg(svg.outerHTML, tickSvgSprite)).to.equal( + true, + 'Should render SVG, from the server response' + ); + + el.setAttribute('icon', 'invalid'); + await elementUpdated(el); + svg = el.shadowRoot.querySelector('svg'); + + expect(svg).to.equal(null, 'SVG element should not exist for invalid icon attribute'); + }); + }); + + describe('Functional Tests', function () { + it('Should support src link in icon attribute', async function () { + createFakeResponse(tickSvgSprite, responseConfigSuccess); + const srcValue = createMockSrc(iconName); + const el = await createAndWaitForLoad(``); + const svg = el.shadowRoot.querySelector('svg'); + + expect(isEqualSvg(svg.outerHTML, tickSvgSprite)).to.equal( + true, + 'Should render SVG, from the server response' + ); + }); + + it('Should support src link in icon property', async function () { + createFakeResponse(tickSvgSprite, responseConfigSuccess); + const el = await createAndWaitForLoad(''); + el.icon = createMockSrc(iconName); + + await elementUpdated(el); + const svg = el.shadowRoot.querySelector('svg'); + + expect(isEqualSvg(svg.outerHTML, tickSvgSprite)).to.equal( + true, + 'Should render SVG, from the server response' + ); + }); + + it('Should not make request for empty icon', async function () { + await createAndWaitForLoad(''); + expect(fetch.callCount).to.equal(0, 'No request should be sent for empty icon'); + }); }); describe('Should Have Correct Properties', function () { it('icon', async function () { - server.respondWith([200, { 'Content-Type': 'image/svg+xml' }, tickSvg]); + createFakeResponse(spriteSvg, responseConfigSuccess); const el = await createAndWaitForLoad(''); expect(el.hasAttribute('icon')).to.equal(false, 'Icon should not have the icon attribute by default'); @@ -152,7 +207,7 @@ describe('icon/Icon', function () { }); it('src', async function () { - server.respondWith([200, { 'Content-Type': 'image/svg+xml' }, tickSvg]); + createFakeResponse(spriteSvg, responseConfigSuccess); const el = await createAndWaitForLoad(''); const srcValue = createMockSrc(iconName); @@ -188,128 +243,41 @@ describe('icon/Icon', function () { ); }); }); - - describe('Functional Tests', function () { - it('should set the src property based on the icon and CDN prefix', async function () { - server.respondWith([200, { 'Content-Type': 'image/svg+xml' }, tickSvg]); - const el = await createAndWaitForLoad(``); - const CDNPrefix = el.getComputedVariable('--cdn-prefix'); - - expect(CDNPrefix, 'CDNPrefix should exist to create the src based on the icon').to.exist; - const expectedSrc = `${CDNPrefix}${iconName}.svg`; - - expect(el.src).to.equal( - expectedSrc, - `The src property should be ${expectedSrc} for the icon ${iconName}` - ); - - el.removeAttribute('icon'); - await elementUpdated(el); - - expect(el.src).to.equal(null, 'The src property should be null when icon removed'); - }); - - it('should make a correct server request based on cdn prefix and the icon if icon is specified', async function () { - server.respondWith([200, { 'Content-Type': 'image/svg+xml' }, tickSvg]); - const uniqueIconName = generateUniqueName(iconName); // to avoid caching - const el = await createAndWaitForLoad(``); - const CDNPrefix = el.getComputedVariable('--cdn-prefix'); - - expect(CDNPrefix, 'CDN prefix should exist to create the src based on the icon').to.exist; - const expectedSrc = `${CDNPrefix}${uniqueIconName}.svg`; - - expect(server.requests.length).to.equal(1, 'Should make one request'); - expect(server.requests[0].url).to.equal( - expectedSrc, - `requested URL should be ${expectedSrc} for the icon ${uniqueIconName}` - ); - }); - - it('should make a correct server request based on src', async function () { - const uniqueIconName = generateUniqueName(iconName); // to avoid caching - const uniqueSrc = createMockSrc(uniqueIconName); - server.respondWith('GET', uniqueSrc, [200, { 'Content-Type': 'image/svg+xml' }, tickSvg]); - - await createAndWaitForLoad(``); - expect(server.requests.length).to.equal(1, 'Should make one request'); - expect(server.requests[0].url).to.equal(uniqueSrc, `requested URL should be ${uniqueSrc}`); - }); - - it('should preload icons', async function () { - let server = sinon.createFakeServer({ respondImmediately: true }); - - const el = await createAndWaitForLoad(''); - const CDNPrefix = el.getComputedVariable('--cdn-prefix'); - - expect(CDNPrefix, 'CDN prefix should exist in order for preload to work properly with icon name').to - .exist; - expect(server.requests.length).to.equal(0, 'No request should be sent for empty icon'); - - const firstUniqueIcon = generateUniqueName(iconName); - const secondUniqueIcon = generateUniqueName(iconName); - const uniqueInvalidIcon = generateUniqueName(iconName); - - const firstUniqueIconSrc = `${CDNPrefix}${firstUniqueIcon}.svg`; - const secondUniqueIconSrc = createMockSrc(secondUniqueIcon); - const uniqueInvalidIconSrc = `${CDNPrefix}${uniqueInvalidIcon}.svg`; - - server.respondWith('GET', firstUniqueIconSrc, [200, { 'Content-Type': 'image/svg+xml' }, tickSvg]); - server.respondWith('GET', secondUniqueIconSrc, [200, { 'Content-Type': 'image/svg+xml' }, tickSvg]); - server.respondWith('GET', uniqueInvalidIconSrc, [404, {}, '']); - - const preloadedIcons = await Promise.all( - preload(firstUniqueIcon, secondUniqueIconSrc, uniqueInvalidIcon) - ); - expect(server.requests.length).to.equal(3, 'Server requests for all preloaded icons should be made'); - expect(checkRequestedUrl(server.requests, firstUniqueIconSrc)).to.equal( - true, - 'should request icons by name with CDN prefix' - ); - expect(checkRequestedUrl(server.requests, secondUniqueIconSrc)).to.equal( - true, - 'should request icons with src' - ); - expect(checkRequestedUrl(server.requests, uniqueInvalidIconSrc)).to.equal( - true, - 'should try to request invalid icon' - ); - expect(preloadedIcons[0].length > 0).to.equal( - true, - 'Should successfully preload icon by name with CDN prefix' - ); - expect(preloadedIcons[1].length > 0).to.equal(true, 'Should successfully preload icons with src'); - expect(preloadedIcons[2], 'Should not preload invalid icon').to.be.undefined; - el.setAttribute('icon', firstUniqueIcon); - await elementUpdated(el); - - expect(server.requests.length).to.equal( - 3, - 'no new requests should be made since icons are already preloaded' - ); - }); - }); }); describe('Should have correct result with configuration resource', function () { - it('should pass config to icon correctly', async function () { - const elConfig = await fixture(''); + it('Should pass base64 config to icon correctly', async function () { + const elConfig = await createAndWaitForLoad(''); elConfig.config.icon.map = { 'tick-base64': tickSvgBase64 }; const elIcon = elConfig.querySelector('ef-icon'); elIcon.icon = 'tick-base64'; - await nextFrame(2); + await elementUpdated(elIcon); + const svg = elIcon.shadowRoot.querySelector('svg'); + await expect(isEqualSvg(svg.outerHTML, tickSvgSprite)).to.equal( + true, + 'Should render SVG, from the server response' + ); + }); + + it('Should pass url config to icon correctly', async function () { + const elConfig = await createAndWaitForLoad(''); + elConfig.config.icon.map = { 'tick-url': tickCDN }; + const elIcon = elConfig.querySelector('ef-icon'); + elIcon.icon = 'tick-url'; + await elementUpdated(elIcon); const svg = elIcon.shadowRoot.querySelector('svg'); - await expect(isEqualSvg(svg.outerHTML, tickSvg)).to.equal( + await expect(isEqualSvg(svg.outerHTML, tickSvgCDN)).to.equal( true, 'Should render SVG, from the server response' ); }); - it('should not render icon when pass config to icon incorrectly', async function () { - const elConfig = await fixture(''); + it('Should not render icon when pass config to icon incorrectly', async function () { + const elConfig = await createAndWaitForLoad(''); elConfig.config.icon.map = { 'tick-base64': 'invalid' + tickSvgBase64 }; const elIcon = elConfig.querySelector('ef-icon'); elIcon.icon = 'tick-base64'; - await nextFrame(2); + await elementUpdated(elIcon); const svg = elIcon.shadowRoot.querySelector('svg'); await expect(svg).to.equal(null, 'SVG element should not exist for invalid icon attribute'); }); diff --git a/packages/elements/src/icon/index.ts b/packages/elements/src/icon/index.ts index fec8ac16e0..8b32898195 100644 --- a/packages/elements/src/icon/index.ts +++ b/packages/elements/src/icon/index.ts @@ -14,10 +14,17 @@ import { customElement } from '@refinitiv-ui/core/decorators/custom-element.js'; import { property } from '@refinitiv-ui/core/decorators/property.js'; import { unsafeSVG } from '@refinitiv-ui/core/directives/unsafe-svg.js'; +import { Deferred, isBase64svg, isUrl } from '@refinitiv-ui/utils/loader.js'; + import { efConfig } from '../configuration/index.js'; import type { Config } from '../configuration/index.js'; import { VERSION } from '../version.js'; import { IconLoader } from './utils/IconLoader.js'; +import { SpriteLoader } from './utils/SpriteLoader.js'; + +export { IconLoader } from './utils/IconLoader.js'; + +export { SpriteLoader } from './utils/SpriteLoader.js'; export { preload } from './utils/IconLoader.js'; @@ -28,7 +35,7 @@ const EmptyTemplate = svg``; * Reusing these templates increases performance dramatically when many icons are rendered. * As the cache key is an absolute URL, we can assume no clashes will occur. */ -const iconTemplateCache = new Map>(); +export const iconTemplateCache = new Map>(); @customElement('ef-icon') export class Icon extends BasicElement { @@ -82,8 +89,9 @@ export class Icon extends BasicElement { public set icon(value: string | null) { const oldValue = this._icon; if (oldValue !== value) { + this.deferIconReady(); this._icon = value; - void this.setIconSrc(); + requestAnimationFrame(() => this.updateRenderer()); this.requestUpdate('icon', oldValue); } } @@ -93,7 +101,7 @@ export class Icon extends BasicElement { * when deprecated features are used. */ private deprecationNotice = new DeprecationNotice( - '`src` attribute and property are deprecated. Use `icon` for attribute and property instead.' + '`src` attribute and property are deprecated. Use `icon` attribute and property instead.' ); private _src: string | null = null; @@ -115,12 +123,7 @@ export class Icon extends BasicElement { public set src(value: string | null) { if (this.src !== value) { this._src = value; - this.clearIcon(); - if (this.icon && this.iconMap) { - void this.loadAndRenderIcon(this.iconMap); - } else if (value) { - void this.loadAndRenderIcon(value); - } + this.icon = value; } if (value && !this.icon) { @@ -141,6 +144,22 @@ export class Icon extends BasicElement { this._template = value; this.requestUpdate(); } + this.iconReady.resolve(); + } + + /** + * A deferred promise representing icon ready. + * It would be resolved when the icon svg has been fetched and parsed, or + * when the icon svg is unavailable/invalid. + */ + private iconReady!: Deferred; + + constructor() { + super(); + this.iconReady = new Deferred(); + // `iconReady` resolves at this stage so that `updateComplete` would be resolvable + // even in the case that `icon` attribute is missing. + this.iconReady.resolve(); } /** @@ -166,12 +185,53 @@ export class Icon extends BasicElement { this.setPrefix(); } + protected override async getUpdateComplete(): Promise { + const result = await super.getUpdateComplete(); + await this.iconReady.promise; + return result; + } + /** - * Helper method, used to set the icon src. + * instantiate a new deferred promise for icon ready if it's not pending already * @returns {void} */ - private async setIconSrc(): Promise { - this.src = this.icon ? await IconLoader.getSrc(this.icon) : null; + private deferIconReady(): void { + if (this.iconReady.isPending()) { + return; + } + this.iconReady = new Deferred(); + } + + /** + * Check if the icon is valid to render + * @returns false if icon value or icon map value is invalid + */ + private isIconValid(): boolean { + if (!this._icon) { + return false; + } + if (this.iconMap && !isBase64svg(this.iconMap) && !isUrl(this.iconMap)) { + return false; + } + return true; + } + + /** + * Update the icon renderer + * @returns {void} + */ + private updateRenderer(): void { + if (!this.isIconValid()) { + return this.clearIcon(); + } + const iconProperty = this._icon!; + if (this.iconMap) { + void this.loadAndRenderIcon(this.iconMap); + } else if (isUrl(iconProperty) || IconLoader.isPrefixResolved) { + void this.loadAndRenderIcon(iconProperty); + } else { + void this.loadAndRenderSpriteIcon(iconProperty); + } } /** @@ -192,6 +252,24 @@ export class Icon extends BasicElement { this.template = await iconTemplateCacheItem; } + /** + * Tries to load get an icon from the sprite url provided + * and the renders this into the icon template. + * @param iconName Name of the svg icon. + * @returns {void} + */ + private async loadAndRenderSpriteIcon(iconName: string): Promise { + const iconTemplateCacheItem = iconTemplateCache.get(iconName); + if (!iconTemplateCacheItem) { + iconTemplateCache.set( + iconName, + SpriteLoader.loadSpriteSVG(iconName).then((body) => svg`${unsafeSVG(body)}`) + ); + return this.loadAndRenderIcon(iconName); // Load again and await cache result + } + this.template = await iconTemplateCacheItem; + } + /** * Get and cache CDN prefix * This is a private URL which is set in the theme @@ -199,11 +277,16 @@ export class Icon extends BasicElement { * @returns {void} */ private setPrefix(): void { - if (!IconLoader.isPrefixSet) { - const CDNPrefix = this.getComputedVariable('--cdn-prefix').replace(/^('|")|('|")$/g, ''); - + // This prefix for individual icons allows supporting custom prefix of self-managed icons. + if (IconLoader.isPrefixPending) { + const CDNPrefix = this.getComputedVariable('--cdn-prefix'); IconLoader.setCdnPrefix(CDNPrefix); } + + if (SpriteLoader.isPrefixPending) { + const CDNSpritePrefix = this.getComputedVariable('--cdn-sprite-prefix'); + SpriteLoader.setCdnPrefix(CDNSpritePrefix); + } } /** diff --git a/packages/elements/src/icon/utils/SpriteLoader.ts b/packages/elements/src/icon/utils/SpriteLoader.ts new file mode 100644 index 0000000000..3b16d693bd --- /dev/null +++ b/packages/elements/src/icon/utils/SpriteLoader.ts @@ -0,0 +1,48 @@ +import { SVGLoader } from '@refinitiv-ui/utils/loader.js'; + +let spriteCache: Promise | undefined; + +/** + * Caches and provides sprite icon SVG + * Uses singleton pattern + */ +class SpriteLoader extends SVGLoader { + public override async getSrc(): Promise { + return await this.getCdnPrefix(); + } + + /** + * Load and Create DOM sprite SVG + * @returns returns the DOM sprite SVG + */ + private async loadSprite(): Promise { + const sprite = await this.loadSVG('sprite/icons'); + if (!sprite) { + throw new Error("SpriteLoader: couldn't load SVG sprite source"); + } + return new DOMParser().parseFromString(sprite, 'image/svg+xml'); + } + + /** + * Load and cache the DOM sprite svg + * Get an svg fragment of DOM sprite svg + * @param iconName Name of svg to load + * @returns returns the svg fragment body + */ + public async loadSpriteSVG(iconName: string): Promise { + if (!spriteCache) { + spriteCache = this.loadSprite(); + } + const sprite = await spriteCache; + const icon = sprite.getElementById(iconName); + return icon ? icon.outerHTML : undefined; + } + + public override reset(): void { + super.reset(); + spriteCache = undefined; + } +} + +const instance = new SpriteLoader(); +export { instance as SpriteLoader }; diff --git a/packages/elements/src/layout/__test__/layout.test.js b/packages/elements/src/layout/__test__/layout.test.js index 79766b2f1c..4a2601f663 100644 --- a/packages/elements/src/layout/__test__/layout.test.js +++ b/packages/elements/src/layout/__test__/layout.test.js @@ -2,7 +2,15 @@ import '@refinitiv-ui/elements/layout'; import '@refinitiv-ui/halo-theme/light/ef-layout.js'; -import { aTimeout, assert, elementUpdated, expect, fixture, oneEvent } from '@refinitiv-ui/test-helpers'; +import { + aTimeout, + assert, + elementUpdated, + expect, + fixture, + getRoundedNumber, + oneEvent +} from '@refinitiv-ui/test-helpers'; describe('layout/Layout', function () { const defaultLayout = ''; @@ -107,8 +115,12 @@ describe('layout/Layout', function () { it('Should be in flex layout', async function () { const el = await fixture(flexLayout); const style = getComputedStyle(el); - assert.equal(style.width, document.body.clientWidth + 'px', 'Width should be 100%'); - assert.equal(style.height, '0px', 'Height should be 0'); + assert.equal( + getRoundedNumber(style.width), + getRoundedNumber(document.body.clientWidth), + 'Width should be 100%' + ); + assert.equal(getRoundedNumber(style.height), getRoundedNumber('0px'), 'Height should be 0'); assert.match(style.display, /flex|flexbox|\-ms\-flexbox/, 'Display should be flex'); expect(style['flex-direction']).to.equal('row', 'Flex direction should be row'); expect(style['flex-wrap']).to.equal('wrap', 'Flex direction should be row'); @@ -123,8 +135,12 @@ describe('layout/Layout', function () { it('Should be in container layout', async function () { const el = await fixture(flexContainerLayout); const style = getComputedStyle(el); - assert.equal(style.width, document.body.clientWidth + 'px', 'Width should be 100%'); - assert.equal(style.height, '0px', 'Height should be 0'); + assert.equal( + getRoundedNumber(style.width), + getRoundedNumber(document.body.clientWidth), + 'Width should be 100%' + ); + assert.equal(getRoundedNumber(style.height), getRoundedNumber('0px'), 'Height should be 0'); assert.match(style.display, /flex|flexbox|\-ms\-flexbox/, 'Display should be flex'); expect(style['flex-direction']).to.equal('column', 'Flex direction should be column'); expect(style['flex-wrap']).to.equal('nowrap', 'Flex direction should be nowrap'); @@ -191,8 +207,12 @@ describe('layout/Layout', function () { detail: { width, height } } = await oneEvent(el, 'resize'); const { offsetWidth, offsetHeight } = el; - expect(width, 'Width should be equall to offsetWidth').to.equal(offsetWidth); - expect(height, 'Height should be equall to offsetHeight').to.equal(offsetHeight); + expect(getRoundedNumber(width), 'Width should be equall to offsetWidth').to.equal( + getRoundedNumber(offsetWidth) + ); + expect(getRoundedNumber(height), 'Height should be equall to offsetHeight').to.equal( + getRoundedNumber(offsetHeight) + ); }); it('debug property is reflected to attribute and vice versa', async function () { diff --git a/packages/elements/src/overlay/__test__/elements/overlay.test.js b/packages/elements/src/overlay/__test__/elements/overlay.test.js index d6c0feef79..dd1101863a 100644 --- a/packages/elements/src/overlay/__test__/elements/overlay.test.js +++ b/packages/elements/src/overlay/__test__/elements/overlay.test.js @@ -1,6 +1,13 @@ import '@refinitiv-ui/elements/overlay'; -import { elementUpdated, expect, fixture, nextFrame, oneEvent } from '@refinitiv-ui/test-helpers'; +import { + elementUpdated, + expect, + fixture, + getRoundedNumber, + nextFrame, + oneEvent +} from '@refinitiv-ui/test-helpers'; import { openedUpdated } from '../mocks/helper.js'; @@ -81,8 +88,8 @@ describe('overlay/elements/Overlay', function () { const screenHeight = document.documentElement.clientHeight; expect(rect.top).to.equal(0); - expect(rect.right).to.equal(screenWidth); - expect(rect.bottom).to.equal(screenHeight); + expect(getRoundedNumber(rect.right)).to.equal(getRoundedNumber(screenWidth)); + expect(getRoundedNumber(rect.bottom)).to.equal(getRoundedNumber(screenHeight)); expect(rect.left).to.equal(0); }); diff --git a/packages/elements/src/overlay/__test__/overlay-position-target.test.js b/packages/elements/src/overlay/__test__/overlay-position-target.test.js index 9b78b1141e..9d0cddaf6f 100644 --- a/packages/elements/src/overlay/__test__/overlay-position-target.test.js +++ b/packages/elements/src/overlay/__test__/overlay-position-target.test.js @@ -1,7 +1,7 @@ import '@refinitiv-ui/elements/overlay'; import '@refinitiv-ui/halo-theme/light/ef-overlay.js'; -import { elementUpdated, expect, nextFrame } from '@refinitiv-ui/test-helpers'; +import { elementUpdated, expect, getRoundedNumber, nextFrame } from '@refinitiv-ui/test-helpers'; import { createPositionTargetFixture, @@ -232,8 +232,8 @@ describe('overlay/PositionTarget', function () { const panelRect = panel.getBoundingClientRect(); const targetRect = target.getBoundingClientRect(); - expect(panelRect.top).to.equal(targetRect.bottom); - expect(panelRect.height).to.equal(borderOffset); + expect(getRoundedNumber(panelRect.top)).to.equal(getRoundedNumber(targetRect.bottom)); + expect(getRoundedNumber(panelRect.height)).to.equal(getRoundedNumber(borderOffset)); }); it('Test left-middle', async function () { @@ -279,8 +279,8 @@ describe('overlay/PositionTarget', function () { const panelRect = panel.getBoundingClientRect(); const targetRect = target.getBoundingClientRect(); - expect(panelRect.left).to.equal(targetRect.right); - expect(panelRect.width).to.equal(borderOffset); + expect(getRoundedNumber(panelRect.left)).to.equal(getRoundedNumber(targetRect.right)); + expect(getRoundedNumber(panelRect.width)).to.equal(getRoundedNumber(borderOffset)); }); }); @@ -306,7 +306,7 @@ describe('overlay/PositionTarget', function () { await openedUpdated(panel); const rect = panel.getBoundingClientRect(); - expect(rect.bottom).to.equal(screenHeight); + expect(getRoundedNumber(rect.bottom)).to.equal(getRoundedNumber(screenHeight)); }); it('Test outside view top-start', async function () { @@ -351,7 +351,7 @@ describe('overlay/PositionTarget', function () { await openedUpdated(panel); const rect = panel.getBoundingClientRect(); - expect(rect.right).to.equal(screenWidth); + expect(getRoundedNumber(rect.right)).to.equal(getRoundedNumber(screenWidth)); }); }); }); diff --git a/packages/elements/src/password-field/index.ts b/packages/elements/src/password-field/index.ts index fa9e421fef..ba3f190c7c 100644 --- a/packages/elements/src/password-field/index.ts +++ b/packages/elements/src/password-field/index.ts @@ -7,13 +7,10 @@ import '@refinitiv-ui/phrasebook/locale/en/password-field.js'; import { Translate, TranslateDirectiveResult, translate } from '@refinitiv-ui/translate'; import { VISUALLY_HIDDEN_STYLE } from '@refinitiv-ui/utils/accessibility.js'; -import { preload } from '../icon/index.js'; import '../icon/index.js'; import { TextField } from '../text-field/index.js'; import { deregisterOverflowTooltip } from '../tooltip/index.js'; -let isEyeOffPreloadRequested = false; - /** * A form control element for password. * @@ -89,10 +86,6 @@ export class PasswordField extends TextField { protected override firstUpdated(changedProperties: PropertyValues): void { super.firstUpdated(changedProperties); - if (!isEyeOffPreloadRequested) { - preload('eye-off'); - isEyeOffPreloadRequested = true; - } // password shouldn't display value on tooltip when value is overflow deregisterOverflowTooltip(this); } diff --git a/packages/elements/src/progress-bar/__test__/progress-bar.test.js b/packages/elements/src/progress-bar/__test__/progress-bar.test.js index ac43eb7c05..6660f3e6ba 100644 --- a/packages/elements/src/progress-bar/__test__/progress-bar.test.js +++ b/packages/elements/src/progress-bar/__test__/progress-bar.test.js @@ -2,7 +2,7 @@ import '@refinitiv-ui/elements/progress-bar'; import '@refinitiv-ui/halo-theme/light/ef-progress-bar.js'; -import { elementUpdated, expect, fixture, oneEvent } from '@refinitiv-ui/test-helpers'; +import { elementUpdated, expect, fixture, getRoundedNumber, oneEvent } from '@refinitiv-ui/test-helpers'; describe('progress-bar/ProgressBar', function () { it('DOM structure is correct', async function () { @@ -24,7 +24,7 @@ describe('progress-bar/ProgressBar', function () { const bar = el.shadowRoot.querySelector('[part~=bar]'); const elWidth = parseFloat(getComputedStyle(el).width); const barWidth = parseFloat(getComputedStyle(bar).width); - expect(parseFloat(barWidth).toFixed()).to.equal(parseFloat(elWidth / 2).toFixed()); + expect(getRoundedNumber(parseFloat(barWidth))).to.equal(getRoundedNumber(parseFloat(elWidth / 2))); }); it('Bar should always show, even when the value is minimal', async function () { diff --git a/packages/elements/src/tree/elements/tree-item.ts b/packages/elements/src/tree/elements/tree-item.ts index 43494b65fd..ad9fbfbe14 100644 --- a/packages/elements/src/tree/elements/tree-item.ts +++ b/packages/elements/src/tree/elements/tree-item.ts @@ -4,13 +4,10 @@ import { property } from '@refinitiv-ui/core/decorators/property.js'; import '../../checkbox/index.js'; import '../../icon/index.js'; -import { preload } from '../../icon/index.js'; import { VERSION } from '../../version.js'; import type { TreeDataItem } from '../helpers/types'; import { CheckedState } from '../managers/tree-manager.js'; -preload('right'); - const emptyTemplate = html``; /** diff --git a/packages/halo-theme/src/custom-elements/ef-icon.less b/packages/halo-theme/src/custom-elements/ef-icon.less index 351b38a231..a8a7b09374 100644 --- a/packages/halo-theme/src/custom-elements/ef-icon.less +++ b/packages/halo-theme/src/custom-elements/ef-icon.less @@ -2,7 +2,7 @@ @import (reference) '../responsive.less'; :host { - --cdn-prefix: 'https://cdn.refinitiv.net/public/libs/elf/assets/elf-theme-halo/resources/icons/'; + --cdn-sprite-prefix: 'https://cdn.refinitiv.net/public/libs/elf/assets/elf-theme-halo/resources/sprites/icons.svg'; font-size: @icon-size; diff --git a/packages/solar-theme/src/custom-elements/ef-icon.less b/packages/solar-theme/src/custom-elements/ef-icon.less index e9ffebf178..062410d45c 100644 --- a/packages/solar-theme/src/custom-elements/ef-icon.less +++ b/packages/solar-theme/src/custom-elements/ef-icon.less @@ -1,5 +1,5 @@ @import '@refinitiv-ui/elemental-theme/src/custom-elements/ef-icon'; :host { - --cdn-prefix: 'https://cdn.refinitiv.net/public/libs/elf/assets/elf-theme-solar/resources/icons/'; + --cdn-sprite-prefix: 'https://cdn.refinitiv.net/public/libs/elf/assets/elf-theme-solar/resources/sprites/icons.svg'; } diff --git a/packages/test-helpers/src/test-helpers.ts b/packages/test-helpers/src/test-helpers.ts index 4ca08c4dd8..278880bede 100644 --- a/packages/test-helpers/src/test-helpers.ts +++ b/packages/test-helpers/src/test-helpers.ts @@ -117,3 +117,19 @@ before(function () { } }; }); + +/** + * Get rounded number from string or number + * @param input number or string or string with 'px' + * @returns rounded number + */ +export const getRoundedNumber = (input: number | string): number => { + // Convert input to a string if it's not already + const strInput = input.toString(); + + // Remove 'px' if present and parse the number + const number = parseFloat(strInput.replace('px', '')); + + // Return the rounded number + return Math.round(number); +}; diff --git a/packages/utils/src/loader.ts b/packages/utils/src/loader.ts index 6e8a47204a..e7fd88c637 100644 --- a/packages/utils/src/loader.ts +++ b/packages/utils/src/loader.ts @@ -1,3 +1,3 @@ export { CDNLoader } from './loader/cdn-loader.js'; -export { SVGLoader } from './loader/svg-loader.js'; +export { SVGLoader, isUrl, isBase64svg } from './loader/svg-loader.js'; export { Deferred } from './loader/deferred.js'; diff --git a/packages/utils/src/loader/cdn-loader.ts b/packages/utils/src/loader/cdn-loader.ts index 4fe4579ba7..634aba8b52 100644 --- a/packages/utils/src/loader/cdn-loader.ts +++ b/packages/utils/src/loader/cdn-loader.ts @@ -1,15 +1,23 @@ import { Deferred } from './deferred.js'; +const FETCH_API_TIMEOUT = 300_000; /* 5 mins */ + /** * Caches and provides any load results, Loaded either by name from CDN or directly by URL. */ export class CDNLoader { private _isPrefixSet = false; + public reset() { + this._isPrefixSet = false; + this.responseCache = new Map>(); + this.cdnPrefix = new Deferred(); + } + /** * Internal response cache */ - private responseCache = new Map>(); + private responseCache = new Map>(); /** * CDN prefix to prepend to src @@ -23,6 +31,19 @@ export class CDNLoader { return this._isPrefixSet; } + /** + * @returns {boolean} clarify whether the prefix has been resolved or not. + */ + public get isPrefixResolved(): boolean { + return this.cdnPrefix.isResolved(); + } + + /** + * @returns {boolean} clarify whether the prefix is pending or not. + */ + public get isPrefixPending(): boolean { + return this.cdnPrefix.isPending(); + } /** * @returns promise, which will be resolved with CDN prefix, once set. */ @@ -32,14 +53,22 @@ export class CDNLoader { /** * Sets CDN prefix to load source. - * Resolves deferred promise with CDN prefix and sets src used to check whether prefix is already set or not. + * Resolves deferred promise with the provided CDN prefix. + * If the prefix is falsy, reject instead. * @param prefix - CDN prefix. * @returns {void} */ public setCdnPrefix(prefix: string): void { + /** + * CDN prefix comes from a value of CSS custom property. + * As this retrieval is expensive performance-wise, + * its value would be settled in a single call. + */ if (prefix) { this.cdnPrefix.resolve(prefix); this._isPrefixSet = true; + } else { + this.cdnPrefix.reject(''); } } @@ -48,40 +77,29 @@ export class CDNLoader { * @param href The location of the SVG to load * @returns Promise of the SVG body */ - private async loadContent(href: string): Promise { + private async loadContent(href: string): Promise { + let response: Response; + const abortController = new AbortController(); + const timeoutId = setTimeout(() => abortController.abort(), FETCH_API_TIMEOUT); try { - const response = await this.xmlRequest(href); - return response; + response = await fetch(href, { signal: abortController.signal }); } catch (e) { - // Failed response... + // Failed response. Prevent the item attached in cache. this.responseCache.delete(href); - return Promise.resolve({ + let errorMessage = ''; + if (e instanceof Error) { + errorMessage = e.message; + } else if (e instanceof Response) { + errorMessage = e.statusText; + } + response = { status: 0, - response: 'Failed to make request', - responseText: 'Failed to make request' - } as XMLHttpRequest); + statusText: errorMessage + } as Response; + } finally { + clearTimeout(timeoutId); } - } - - /** - * Load and support on IE - * @param href The source or location - * @returns XMLHttpRequest objects after to interact servers. - */ - private async xmlRequest(href: string): Promise { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open('GET', href); - xhr.onload = (): void => { - if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status < 400) { - resolve(xhr); - } else { - reject(xhr); - } - }; - xhr.onerror = (): void => reject(xhr); - xhr.send(); - }); + return response; } /** @@ -89,7 +107,7 @@ export class CDNLoader { * @param src name or Source location. * @returns Promise which will be resolved with response body */ - public async load(src: string): Promise { + public async load(src: string): Promise { if (src) { if (!this.responseCache.has(src)) { this.responseCache.set(src, this.loadContent(src)); diff --git a/packages/utils/src/loader/deferred.ts b/packages/utils/src/loader/deferred.ts index b20f2fc718..33b612b62d 100644 --- a/packages/utils/src/loader/deferred.ts +++ b/packages/utils/src/loader/deferred.ts @@ -1,19 +1,25 @@ type PromiseField = (value: T) => void; +enum PromiseState { + pending, + resolved, + rejected +} + /** * Creates pending promise, that can be resolved or rejected manually */ export class Deferred { - // eslint-disable-next-line @typescript-eslint/no-empty-function - private _resolve: PromiseField = () => {}; + private _resolve!: PromiseField; - // eslint-disable-next-line @typescript-eslint/no-empty-function - private _reject: PromiseField = () => {}; + private _reject!: PromiseField; + + private state = PromiseState.pending; private _promise: Promise = new Promise((resolve, reject) => { this._reject = reject; this._resolve = resolve; - }); + }).catch((value: T) => value); /* prevent uncaught promise console error upon rejection */ public get promise(): Promise { return this._promise; @@ -21,9 +27,35 @@ export class Deferred { public resolve(value: T): void { this._resolve(value); + this.state = PromiseState.resolved; } public reject(value: T): void { this._reject(value); + this.state = PromiseState.rejected; + } + + public isSettled(): boolean { + return this.state === PromiseState.rejected || this.state === PromiseState.resolved; + } + + public isPending(): boolean { + return this.state === PromiseState.pending; + } + + public isRejected(): boolean { + return this.state === PromiseState.rejected; + } + + public isResolved(): boolean { + return this.state === PromiseState.resolved; + } + + public reset(): void { + this.state = PromiseState.pending; + this._promise = new Promise((resolve, reject) => { + this._reject = reject; + this._resolve = resolve; + }).catch((value: T) => value); } } diff --git a/packages/utils/src/loader/svg-loader.ts b/packages/utils/src/loader/svg-loader.ts index e453bf8426..d83e4b0d10 100644 --- a/packages/utils/src/loader/svg-loader.ts +++ b/packages/utils/src/loader/svg-loader.ts @@ -5,15 +5,15 @@ import { CDNLoader } from './cdn-loader.js'; * @param str String to test * @returns is URL */ -const isUrl = (str: string): boolean => /^(https?:\/{2}|\.?\/)/i.test(str); +export const isUrl = (str: string): boolean => /^(?:https:\/{2}|\.?\/).*\.svg/i.test(str); /** * Checks a string to see if it's a base64 URL * @param str String to test * @returns is Base64 */ -const isBase64svg = (str: string): boolean => - /^data:image\/(?:svg|svg\+xml);base64,[a-zA-Z0-9+/]+={0,2}/i.test(str); +export const isBase64svg = (str: string): boolean => + /^data:image\/(svg|svg\+xml);base64,[a-zA-Z0-9+/]+={0,2}/i.test(str); /** * Strips any event attributes which could be used to @@ -55,10 +55,10 @@ const stripUnsafeNodes = (...elements: Node[]): void => { * @param response Request response to test * @returns Is valid SVG */ -const isValidResponse = (response: XMLHttpRequest | undefined): response is XMLHttpRequest => { - return ( - !!response && response.status === 200 && response.getResponseHeader('content-type') === 'image/svg+xml' - ); +const isValidResponse = (response: Response | undefined): response is Response => { + // Header might not be present in case of network error such as CORS issue + const isSVG = Boolean(response?.headers?.get('content-type')?.startsWith('image/svg+xml')); + return Boolean(response) && Boolean(response?.ok) && response?.status === 200 && isSVG; }; /** @@ -66,10 +66,12 @@ const isValidResponse = (response: XMLHttpRequest | undefined): response is XMLH * @param response Response to extract SVG from * @returns SVG result or null */ -const extractSafeSVG = (response: XMLHttpRequest | undefined): SVGElement | null => { - if (isValidResponse(response) && response.responseXML) { - const svgDocument = response.responseXML.cloneNode(true) as Document; - const svg = svgDocument.firstElementChild; +const extractSafeSVG = async (response: Response | undefined): Promise => { + if (isValidResponse(response)) { + // clone to support preload to prevent locked response + const responseText = await response.clone().text(); + const svgDocument = new window.DOMParser().parseFromString(responseText, 'image/svg+xml'); + const svg = svgDocument.children[svgDocument.children.length - 1]; if (svg instanceof SVGElement) { stripUnsafeNodes(svg); return svg; @@ -105,8 +107,12 @@ export class SVGLoader extends CDNLoader { if (!name) { return; } + const src = await this.getSrc(name); const response = await this.load(src); - return extractSafeSVG(response)?.outerHTML; + const svg = await extractSafeSVG(response); + const svgBody = svg?.outerHTML; + + return svgBody; } }