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