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,40 @@
<template>
<div v-html="svgContent" class="inline-icon" />
</template>
<script lang="ts">
import {
defineComponent,
PropType,
inject,
} from 'vue';
import { useSvgLoader } from './UseSvgLoader';
import { IconName } from './IconName';
export default defineComponent({
props: {
icon: {
type: String as PropType<IconName>,
required: true,
},
},
setup(props) {
const useSvgLoaderHook = inject('useSvgLoaderHook', useSvgLoader);
const { svgContent } = useSvgLoaderHook(() => props.icon);
return { svgContent };
},
});
</script>
<style lang="scss" scoped>
.inline-icon {
display: inline-block;
::v-deep svg { // using ::v-deep because when v-html is used the content doesn't go through Vue's template compiler.
display: inline-block;
height: 1em;
overflow: visible;
vertical-align: -0.125em;
}
}
</style>

View File

@@ -0,0 +1,22 @@
export const IconNames = [
'magnifying-glass',
'copy',
'circle-info',
'user-secret',
'tag',
'github',
'face-smile',
'globe',
'desktop',
'xmark',
'battery-half',
'battery-full',
'folder',
'folder-open',
'left-right',
'file-arrow-down',
'floppy-disk',
'play',
] as const;
export type IconName = typeof IconNames[number];

View File

@@ -0,0 +1,92 @@
import {
WatchSource, readonly, ref, watch,
} from 'vue';
import { AsyncLazy } from '@/infrastructure/Threading/AsyncLazy';
import { IconName } from './IconName';
export function useSvgLoader(
iconWatcher: WatchSource<IconName>,
loaders: FileLoaders = RawSvgLoaders,
) {
const svgContent = ref<string>('');
watch(iconWatcher, async (iconName) => {
svgContent.value = await lazyLoadSvg(iconName, loaders);
}, { immediate: true });
return {
svgContent: readonly(svgContent),
};
}
export function clearIconCache() {
LazyIconCache.clear();
}
export type FileLoaders = Record<string, () => Promise<string>>;
const LazyIconCache = new Map<IconName, AsyncLazy<string>>();
async function lazyLoadSvg(name: IconName, loaders: FileLoaders): Promise<string> {
let iconLoader = LazyIconCache.get(name);
if (!iconLoader) {
iconLoader = new AsyncLazy<string>(() => loadSvg(name, loaders));
LazyIconCache.set(name, iconLoader);
}
const icon = await iconLoader.getValue();
return icon;
}
async function loadSvg(name: IconName, loaders: FileLoaders): Promise<string> {
const iconPath = `/assets/icons/${name}.svg`;
const loader = loaders[iconPath];
if (!loader) {
throw new Error(`missing icon for "${name}" in "${iconPath}"`);
}
const svgContent = await loader();
const modifiedContent = modifySvg(svgContent);
return modifiedContent;
}
const RawSvgLoaders = import.meta.glob('@/presentation/assets/icons/**/*.svg', {
as: 'raw', // This will load the SVG file content as a string.
/*
Using `eager: true` to preload all icons.
Pros:
- Speed: Icons are instantly accessible post-initial load.
Cons:
- Increased initial load time due to preloading of all icons.
- Increased bundle size.
*/
eager: false,
});
function modifySvg(svgSource: string): string {
const parser = new DOMParser();
const doc = parser.parseFromString(svgSource, 'image/svg+xml');
let svgRoot = doc.documentElement;
svgRoot = removeSvgComments(svgRoot);
svgRoot = fillSvgCurrentColor(svgRoot);
return new XMLSerializer()
.serializeToString(svgRoot);
}
function removeSvgComments(svgRoot: HTMLElement): HTMLElement {
const comments = Array.from(svgRoot.childNodes).filter(
(node) => node.nodeType === Node.COMMENT_NODE,
);
for (const comment of comments) {
svgRoot.removeChild(comment);
}
Array.from(svgRoot.children).forEach((child) => {
removeSvgComments(child as HTMLElement);
});
return svgRoot;
}
function fillSvgCurrentColor(svgRoot: HTMLElement): HTMLElement {
svgRoot.querySelectorAll('path').forEach((el: Element) => {
el.setAttribute('fill', 'currentColor');
});
return svgRoot;
}

View File

@@ -10,9 +10,7 @@
class="dialog__close-button"
@click="hide"
>
<font-awesome-icon
:icon="['fas', 'times']"
/>
<AppIcon icon="xmark" />
</div>
</div>
</ModalContainer>
@@ -20,11 +18,13 @@
<script lang="ts">
import { defineComponent, computed } from 'vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import ModalContainer from './ModalContainer.vue';
export default defineComponent({
components: {
ModalContainer,
AppIcon,
},
emits: {
/* eslint-disable @typescript-eslint/no-unused-vars */