Implement new UI component for icons #230

- Introduce `AppIcon.vue`, offering improved performance over the
  previous `fort-awesome` dependency. This implementation reduces bundle
  size by 67.31KB (tested for web using `npm run build -- --mode prod`).
- Migrate Font Awesome 5 icons to Font Awesome 6.

This commit facilitates migration to Vue 3.0 (#230) and ensures no Vue
component remains tightly bound to a specific Vue version, enhancing
code portability.

Font Awesome license is not included because Font Awesome revokes its
right:

> "Attribution is no longer required as of Font Awesome 3.0"
>
> Sources:
>
> - https://fontawesome.com/v4/license/ (archived: https://web.archive.org/web/20231003213441/https://fontawesome.com/v4/license/, https://archive.ph/Yy9j5)
> - https://github.com/FortAwesome/Font-Awesome/wiki (archived: https://web.archive.org/web/20231003214646/https://github.com/FortAwesome/Font-Awesome/wiki, https://archive.ph/C6sXv)

This commit removes following third-party production dependencies:

- `@fortawesome/vue-fontawesome`
- `@fortawesome/free-solid-svg-icons`
- `@fortawesome/free-regular-svg-icons`
- `@fortawesome/free-brands-svg-icons`
- `@fortawesome/fontawesome-svg-core`
This commit is contained in:
undergroundwires
2023-10-11 18:38:19 +02:00
parent 698b570ee6
commit 48730bca05
43 changed files with 568 additions and 204 deletions

View File

@@ -0,0 +1,20 @@
import {
describe, it, expect,
} from 'vitest';
import { IconNames } from '@/presentation/components/Shared/Icon/IconName';
import { useSvgLoader } from '@/presentation/components/Shared/Icon/UseSvgLoader';
import { waitForValueChange } from '@tests/shared/WaitForValueChange';
describe('useSvgLoader', () => {
describe('can load all SVGs', () => {
for (const iconName of IconNames) {
it(iconName, async () => {
// act
const { svgContent } = useSvgLoader(() => iconName);
await waitForValueChange(svgContent);
// assert
expect(svgContent.value).toBeTruthy();
});
}
});
});

View File

@@ -0,0 +1,17 @@
import { WatchSource, watch } from 'vue';
export function waitForValueChange<T>(valueWatcher: WatchSource<T>, timeoutMs = 2000): Promise<T> {
return new Promise<T>((resolve, reject) => {
const unwatch = watch(valueWatcher, (newValue, oldValue) => {
if (newValue !== oldValue) {
unwatch();
resolve(newValue);
}
}, { immediate: false });
setTimeout(() => {
unwatch();
reject(new Error('Timeout waiting for value to change.'));
}, timeoutMs);
});
}

View File

@@ -0,0 +1,100 @@
import {
describe, it, expect,
} from 'vitest';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import { IconName } from '@/presentation/components/Shared/Icon/IconName';
import { UseSvgLoaderStub } from '@tests/unit/shared/Stubs/UseSvgLoaderStub';
describe('AppIcon.vue', () => {
it('renders the correct SVG content based on the icon prop', async () => {
// arrange
const expectedIconName: IconName = 'magnifying-glass';
const expectedIconContent = '<svg id="expected-svg" />';
const svgLoaderStub = new UseSvgLoaderStub();
svgLoaderStub.withSvgIcon(expectedIconName, expectedIconContent);
// act
const wrapper = mountComponent({
iconPropValue: expectedIconName,
loader: svgLoaderStub,
});
await nextTick();
// assert
const actualSvg = extractAndNormalizeSvg(wrapper.html());
const expectedSvg = extractAndNormalizeSvg(expectedIconContent);
expect(actualSvg).to.equal(
expectedSvg,
`Expected:\n\n${expectedSvg}\n\nActual:\n\n${actualSvg}`,
);
});
it('updates the SVG content when the icon prop changes', async () => {
// arrange
const initialIconName: IconName = 'magnifying-glass';
const updatedIconName: IconName = 'copy';
const updatedIconContent = '<svg id="updated-svg" />';
const svgLoaderStub = new UseSvgLoaderStub();
svgLoaderStub.withSvgIcon(initialIconName, '<svg id="initial-svg" />');
svgLoaderStub.withSvgIcon(updatedIconName, updatedIconContent);
// act
const wrapper = mountComponent({
iconPropValue: initialIconName,
loader: svgLoaderStub,
});
await wrapper.setProps({ icon: updatedIconName });
await nextTick();
// assert
const actualSvg = extractAndNormalizeSvg(wrapper.html());
const expectedSvg = extractAndNormalizeSvg(updatedIconContent);
expect(actualSvg).to.equal(
expectedSvg,
`Expected:\n\n${expectedSvg}\n\nActual:\n\n${actualSvg}`,
);
});
});
function mountComponent(options: {
readonly iconPropValue: IconName,
readonly loader: UseSvgLoaderStub,
}) {
return shallowMount(AppIcon, {
propsData: {
icon: options.iconPropValue,
},
provide: {
useSvgLoaderHook: options.loader.get(),
},
});
}
function extractAndNormalizeSvg(svgString: string): string {
const svg = extractSvg(svgString);
return normalizeSvg(svg);
}
function extractSvg(svgString: string): string {
const svgMatches = svgString.match(/<svg[\s\S]*?(<\/svg>|\/>)/g);
if (!svgMatches || svgMatches.length === 0) {
throw new Error(`No SVG found in: ${svgString}`);
}
if (svgMatches.length > 1) {
throw new Error(`Multiple SVGs found in: ${svgString}`);
}
const svgContent = svgMatches[0];
return svgContent;
}
function normalizeSvg(svgString: string): string {
return svgString
.replace(/\n/g, '') // Remove newlines
.replace(/\s+/g, ' ') // Replace all whitespace sequences with a single space
.replace(/> </g, '><') // Remove spaces between tags
.replace(/ <\//g, '</') // Remove spaces before closing tags
.replace(/\s+\/>/g, '/>') // Remove spaces before self-closing tag end
.replace(/<(\w+)([^>]*)><\/\1>/g, '<$1$2/>') // Convert to self-closing SVG tags
.trim(); // Remove leading and trailing spaces
}

View File

@@ -0,0 +1,160 @@
import {
describe, it, expect, beforeEach,
} from 'vitest';
import { ref } from 'vue';
import { IconName } from '@/presentation/components/Shared/Icon/IconName';
import { FileLoaders, clearIconCache, useSvgLoader } from '@/presentation/components/Shared/Icon/UseSvgLoader';
import { waitForValueChange } from '@tests/shared/WaitForValueChange';
describe('useSvgLoader', () => {
beforeEach(() => {
clearIconCache();
});
describe('SVG loading', () => {
it('renders initial SVG content based on icon name', async () => {
// arrange
const expectedIconName: IconName = 'magnifying-glass';
const expectedIconContent = '<svg id="expected-content"/>';
const { loaders, addIcon } = useSvgMock();
addIcon(expectedIconName, expectedIconContent);
// act
const { svgContent } = useSvgLoader(() => expectedIconName, loaders);
await waitForValueChange(svgContent);
// assert
expect(svgContent.value).to.equal(expectedIconContent);
});
it('updates SVG content when icon name changes', async () => {
// arrange
const initialIconName: IconName = 'magnifying-glass';
const iconName = ref<IconName>(initialIconName);
const initialIconContent = '<svg id="initial"/>';
const updatedIconName: IconName = 'copy';
const updatedIconContent = '<svg id="updated"/>';
const { addIcon, loaders } = useSvgMock();
addIcon(initialIconName, initialIconContent);
addIcon(updatedIconName, updatedIconContent);
// act
const { svgContent } = useSvgLoader(() => iconName.value, loaders);
await waitForValueChange(svgContent);
iconName.value = updatedIconName;
await waitForValueChange(svgContent);
// assert
expect(svgContent.value).to.equal(updatedIconContent);
});
it('lazy loads SVG icons and does not preload', async () => {
// arrange
const expectedIconName: IconName = 'magnifying-glass';
const unexpectedIconName: IconName = 'copy';
const { addIcon, getSvgFetchCount, loaders } = useSvgMock();
addIcon(expectedIconName);
addIcon(unexpectedIconName);
// act
const { svgContent } = useSvgLoader(() => expectedIconName, loaders);
await waitForValueChange(svgContent);
// assert
expect(getSvgFetchCount(expectedIconName)).to.equal(1);
expect(getSvgFetchCount(unexpectedIconName)).to.equal(0);
});
it('avoids loading same SVG content multiple times for concurrent calls', async () => {
// arrange
const expectedIconName: IconName = 'magnifying-glass';
const { addIcon, getSvgFetchCount, loaders } = useSvgMock();
addIcon(expectedIconName);
// act
const { svgContent: svgContent1 } = useSvgLoader(() => expectedIconName, loaders);
const { svgContent: svgContent2 } = useSvgLoader(() => expectedIconName, loaders);
await Promise.all([
waitForValueChange(svgContent1),
waitForValueChange(svgContent2),
]);
// assert
expect(getSvgFetchCount(expectedIconName)).to.equal(1);
});
});
describe('SVG content manipulation', () => {
it('sets path fill color to currentColor', async () => {
// arrange
const expectedIconName: IconName = 'magnifying-glass';
const { addIcon, loaders } = useSvgMock();
addIcon(expectedIconName, '<svg id="svg-with-paths"><path /><path /></svg>');
// act
const { svgContent } = useSvgLoader(() => expectedIconName, loaders);
await waitForValueChange(svgContent);
// assert
const svgElement = new DOMParser().parseFromString(svgContent.value, 'image/svg+xml');
const pathElements = Array.from(svgElement.querySelectorAll('path'));
expect(pathElements).to.have.lengthOf(2, svgContent.value);
const fillAttributeValues = pathElements.map((el: Element) => el.getAttribute('fill'));
expect(fillAttributeValues).to.have.members(['currentColor', 'currentColor']);
});
it('removes comments from loaded SVG', async () => {
// arrange
const commentLine = '<!-- This is a comment -->';
const expectedIconName: IconName = 'magnifying-glass';
const { addIcon, loaders } = useSvgMock();
addIcon(expectedIconName, `<svg>${commentLine}<path></path></svg>`);
// act
const { svgContent } = useSvgLoader(() => expectedIconName, loaders);
await waitForValueChange(svgContent);
// assert
expect(svgContent.value).not.to.include(commentLine);
});
});
describe('icon cache management', () => {
it('reloads SVG content after clearing cache', async () => {
// arrange
const expectedIconName: IconName = 'magnifying-glass';
const { addIcon, getSvgFetchCount, loaders } = useSvgMock();
addIcon(expectedIconName);
// act
const { svgContent } = useSvgLoader(() => expectedIconName, loaders);
await waitForValueChange(svgContent);
expect(getSvgFetchCount(expectedIconName)).to.equal(1);
clearIconCache();
const { svgContent: newSvgContent } = useSvgLoader(() => expectedIconName, loaders);
await waitForValueChange(newSvgContent);
// assert
expect(getSvgFetchCount(expectedIconName)).to.equal(2);
});
});
});
function useSvgMock() {
const ICON_PATH_PREFIX = '/assets/icons/';
function getPath(iconName: IconName) {
return `${ICON_PATH_PREFIX}${iconName}.svg`;
}
const svgFetchCount = {} as Record<IconName, number>;
const loaders = {} as FileLoaders;
function addIcon(iconName: IconName, svgContent = '<svg id="stub" />') {
const path = getPath(iconName);
svgFetchCount[iconName] = 0;
loaders[path] = () => {
svgFetchCount[iconName] += 1;
return Promise.resolve(svgContent);
};
}
function getSvgFetchCount(iconName: IconName): number {
return svgFetchCount[iconName];
}
return {
loaders,
getSvgFetchCount,
getPath,
addIcon,
};
}

View File

@@ -4,12 +4,13 @@ export async function expectThrowsAsync(
method: () => Promise<unknown>,
errorMessage: string,
) {
let error: Error;
let error: Error | undefined;
try {
await method();
} catch (err) {
error = err;
}
expect(error).toBeDefined();
expect(error).to.be.an(Error.name);
if (errorMessage) {
expect(error.message).to.equal(errorMessage);

View File

@@ -0,0 +1,31 @@
import {
WatchSource, computed, ref, watch,
} from 'vue';
import { IconName } from '@/presentation/components/Shared/Icon/IconName';
import { useSvgLoader } from '@/presentation/components/Shared/Icon/UseSvgLoader';
export class UseSvgLoaderStub {
private readonly icons = new Map<IconName, string>();
public withSvgIcon(name: IconName, svgContent: string): this {
this.icons.set(name, svgContent);
return this;
}
public get(): typeof useSvgLoader {
return (iconWatcher: WatchSource<IconName>) => {
const iconName = ref<IconName | undefined>();
watch(iconWatcher, (newIconName) => {
iconName.value = newIconName;
}, { immediate: true });
return {
svgContent: computed<string>(() => {
if (!iconName.value) {
return '';
}
return this.icons.get(iconName.value) || '';
}),
};
};
}
}