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

116
package-lock.json generated
View File

@@ -6,15 +6,10 @@
"packages": {
"": {
"name": "privacy.sexy",
"version": "0.12.3",
"version": "0.12.4",
"hasInstallScript": true,
"dependencies": {
"@floating-ui/vue": "^1.0.2",
"@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-brands-svg-icons": "^6.4.0",
"@fortawesome/free-regular-svg-icons": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/vue-fontawesome": "^2.0.9",
"@juggle/resize-observer": "^3.4.0",
"ace-builds": "^1.23.4",
"cross-fetch": "^4.0.0",
@@ -2578,72 +2573,6 @@
}
}
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.2.tgz",
"integrity": "sha512-1DgP7f+XQIJbLFCTX1V2QnxVmpLdKdzzo2k8EmvDOePfchaIGQ9eCHj2up3/jNEbZuBqel5OxiaOJf37TWauRA==",
"hasInstallScript": true,
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.4.2.tgz",
"integrity": "sha512-gjYDSKv3TrM2sLTOKBc5rH9ckje8Wrwgx1CxAPbN5N3Fm4prfi7NsJVWd1jklp7i5uSCVwhZS5qlhMXqLrpAIg==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.4.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.4.2.tgz",
"integrity": "sha512-LKOwJX0I7+mR/cvvf6qIiqcERbdnY+24zgpUSouySml+5w8B4BJOx8EhDR/FTKAu06W12fmUIcv6lzPSwYKGGg==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.4.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-regular-svg-icons": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.4.2.tgz",
"integrity": "sha512-0+sIUWnkgTVVXVAPQmW4vxb9ZTHv0WstOa3rBx9iPxrrrDH6bNLsDYuwXF9b6fGm+iR7DKQvQshUH/FJm3ed9Q==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.4.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.4.2.tgz",
"integrity": "sha512-sYwXurXUEQS32fZz9hVCUUv/xu49PEJEyUOsA51l6PU/qVgfbTb2glsTEaJngVVT8VqBATRIdh7XVgV1JF1LkA==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.4.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/vue-fontawesome": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-2.0.10.tgz",
"integrity": "sha512-OTETSXz+3ygD2OK2/vy82cmUBpuJqeOAg4gfnnv+f2Rir1tDIhQg026Q3NQxznq83ZLz8iNqGG9XJm26inpDeg==",
"peerDependencies": {
"@fortawesome/fontawesome-svg-core": "~1 || ~6",
"vue": "~2"
}
},
"node_modules/@hapi/hoek": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
@@ -23015,49 +22944,6 @@
}
}
},
"@fortawesome/fontawesome-common-types": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.2.tgz",
"integrity": "sha512-1DgP7f+XQIJbLFCTX1V2QnxVmpLdKdzzo2k8EmvDOePfchaIGQ9eCHj2up3/jNEbZuBqel5OxiaOJf37TWauRA=="
},
"@fortawesome/fontawesome-svg-core": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.4.2.tgz",
"integrity": "sha512-gjYDSKv3TrM2sLTOKBc5rH9ckje8Wrwgx1CxAPbN5N3Fm4prfi7NsJVWd1jklp7i5uSCVwhZS5qlhMXqLrpAIg==",
"requires": {
"@fortawesome/fontawesome-common-types": "6.4.2"
}
},
"@fortawesome/free-brands-svg-icons": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.4.2.tgz",
"integrity": "sha512-LKOwJX0I7+mR/cvvf6qIiqcERbdnY+24zgpUSouySml+5w8B4BJOx8EhDR/FTKAu06W12fmUIcv6lzPSwYKGGg==",
"requires": {
"@fortawesome/fontawesome-common-types": "6.4.2"
}
},
"@fortawesome/free-regular-svg-icons": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.4.2.tgz",
"integrity": "sha512-0+sIUWnkgTVVXVAPQmW4vxb9ZTHv0WstOa3rBx9iPxrrrDH6bNLsDYuwXF9b6fGm+iR7DKQvQshUH/FJm3ed9Q==",
"requires": {
"@fortawesome/fontawesome-common-types": "6.4.2"
}
},
"@fortawesome/free-solid-svg-icons": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.4.2.tgz",
"integrity": "sha512-sYwXurXUEQS32fZz9hVCUUv/xu49PEJEyUOsA51l6PU/qVgfbTb2glsTEaJngVVT8VqBATRIdh7XVgV1JF1LkA==",
"requires": {
"@fortawesome/fontawesome-common-types": "6.4.2"
}
},
"@fortawesome/vue-fontawesome": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-2.0.10.tgz",
"integrity": "sha512-OTETSXz+3ygD2OK2/vy82cmUBpuJqeOAg4gfnnv+f2Rir1tDIhQg026Q3NQxznq83ZLz8iNqGG9XJm26inpDeg==",
"requires": {}
},
"@hapi/hoek": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",

View File

@@ -35,11 +35,6 @@
},
"dependencies": {
"@floating-ui/vue": "^1.0.2",
"@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-brands-svg-icons": "^6.4.0",
"@fortawesome/free-regular-svg-icons": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/vue-fontawesome": "^2.0.9",
"@juggle/resize-observer": "^3.4.0",
"ace-builds": "^1.23.4",
"cross-fetch": "^4.0.0",

View File

@@ -0,0 +1 @@
<!-- Source: Font Awesome 6 --><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M464 160c8.8 0 16 7.2 16 16V336c0 8.8-7.2 16-16 16H80c-8.8 0-16-7.2-16-16V176c0-8.8 7.2-16 16-16H464zM80 96C35.8 96 0 131.8 0 176V336c0 44.2 35.8 80 80 80H464c44.2 0 80-35.8 80-80V320c17.7 0 32-14.3 32-32V224c0-17.7-14.3-32-32-32V176c0-44.2-35.8-80-80-80H80zm368 96H96V320H448V192z"/></svg>

After

Width:  |  Height:  |  Size: 392 B

View File

@@ -0,0 +1 @@
<!-- Source: Font Awesome 6 --><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M464 160c8.8 0 16 7.2 16 16V336c0 8.8-7.2 16-16 16H80c-8.8 0-16-7.2-16-16V176c0-8.8 7.2-16 16-16H464zM80 96C35.8 96 0 131.8 0 176V336c0 44.2 35.8 80 80 80H464c44.2 0 80-35.8 80-80V320c17.7 0 32-14.3 32-32V224c0-17.7-14.3-32-32-32V176c0-44.2-35.8-80-80-80H80zm208 96H96V320H288V192z"/></svg>

After

Width:  |  Height:  |  Size: 392 B

View File

@@ -0,0 +1 @@
<!-- Source: Font Awesome 6 --><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM216 336h24V272H216c-13.3 0-24-10.7-24-24s10.7-24 24-24h48c13.3 0 24 10.7 24 24v88h8c13.3 0 24 10.7 24 24s-10.7 24-24 24H216c-13.3 0-24-10.7-24-24s10.7-24 24-24zm40-208a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"/></svg>

After

Width:  |  Height:  |  Size: 363 B

View File

@@ -0,0 +1 @@
<!-- Source: Font Awesome 6 --><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M208 0H332.1c12.7 0 24.9 5.1 33.9 14.1l67.9 67.9c9 9 14.1 21.2 14.1 33.9V336c0 26.5-21.5 48-48 48H208c-26.5 0-48-21.5-48-48V48c0-26.5 21.5-48 48-48zM48 128h80v64H64V448H256V416h64v48c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V176c0-26.5 21.5-48 48-48z"/></svg>

After

Width:  |  Height:  |  Size: 365 B

View File

@@ -0,0 +1 @@
<!-- Source: Font Awesome 6 --><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M64 0C28.7 0 0 28.7 0 64V352c0 35.3 28.7 64 64 64H240l-10.7 32H160c-17.7 0-32 14.3-32 32s14.3 32 32 32H416c17.7 0 32-14.3 32-32s-14.3-32-32-32H346.7L336 416H512c35.3 0 64-28.7 64-64V64c0-35.3-28.7-64-64-64H64zM512 64V288H64V64H512z"/></svg>

After

Width:  |  Height:  |  Size: 342 B

View File

@@ -0,0 +1 @@
<!-- Source: Font Awesome 6 --><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M464 256A208 208 0 1 0 48 256a208 208 0 1 0 416 0zM0 256a256 256 0 1 1 512 0A256 256 0 1 1 0 256zm177.6 62.1C192.8 334.5 218.8 352 256 352s63.2-17.5 78.4-33.9c9-9.7 24.2-10.4 33.9-1.4s10.4 24.2 1.4 33.9c-22 23.8-60 49.4-113.6 49.4s-91.7-25.5-113.6-49.4c-9-9.7-8.4-24.9 1.4-33.9s24.9-8.4 33.9 1.4zM144.4 208a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm192-32a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"/></svg>

After

Width:  |  Height:  |  Size: 495 B

View File

@@ -0,0 +1 @@
<!-- Source: Font Awesome 6 --><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M64 0C28.7 0 0 28.7 0 64V448c0 35.3 28.7 64 64 64H320c35.3 0 64-28.7 64-64V160H256c-17.7 0-32-14.3-32-32V0H64zM256 0V128H384L256 0zM216 232V334.1l31-31c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-72 72c-9.4 9.4-24.6 9.4-33.9 0l-72-72c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l31 31V232c0-13.3 10.7-24 24-24s24 10.7 24 24z"/></svg>

After

Width:  |  Height:  |  Size: 428 B

View File

@@ -0,0 +1 @@
<!-- Source: Font Awesome 6 --><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M64 32C28.7 32 0 60.7 0 96V416c0 35.3 28.7 64 64 64H384c35.3 0 64-28.7 64-64V173.3c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7 32H64zm0 96c0-17.7 14.3-32 32-32H288c17.7 0 32 14.3 32 32v64c0 17.7-14.3 32-32 32H96c-17.7 0-32-14.3-32-32V128zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/></svg>

After

Width:  |  Height:  |  Size: 407 B

View File

@@ -0,0 +1 @@
<!-- Source: Font Awesome 6 --><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M384 480h48c11.4 0 21.9-6 27.6-15.9l112-192c5.8-9.9 5.8-22.1 .1-32.1S555.5 224 544 224H144c-11.4 0-21.9 6-27.6 15.9L48 357.1V96c0-8.8 7.2-16 16-16H181.5c4.2 0 8.3 1.7 11.3 4.7l26.5 26.5c21 21 49.5 32.8 79.2 32.8H416c8.8 0 16 7.2 16 16v32h48V160c0-35.3-28.7-64-64-64H298.5c-17 0-33.3-6.7-45.3-18.7L226.7 50.7c-12-12-28.3-18.7-45.3-18.7H64C28.7 32 0 60.7 0 96V416c0 35.3 28.7 64 64 64H87.7 384z"/></svg>

After

Width:  |  Height:  |  Size: 503 B

View File

@@ -0,0 +1 @@
<!-- Source: Font Awesome 6 --><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M0 96C0 60.7 28.7 32 64 32H196.1c19.1 0 37.4 7.6 50.9 21.1L289.9 96H448c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V96zM64 80c-8.8 0-16 7.2-16 16V416c0 8.8 7.2 16 16 16H448c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16H286.6c-10.6 0-20.8-4.2-28.3-11.7L213.1 87c-4.5-4.5-10.6-7-17-7H64z"/></svg>

After

Width:  |  Height:  |  Size: 419 B

View File

@@ -0,0 +1 @@
<!-- Source: Font Awesome 6 --><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1 @@
<!-- Source: Font Awesome 6 --><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M352 256c0 22.2-1.2 43.6-3.3 64H163.3c-2.2-20.4-3.3-41.8-3.3-64s1.2-43.6 3.3-64H348.7c2.2 20.4 3.3 41.8 3.3 64zm28.8-64H503.9c5.3 20.5 8.1 41.9 8.1 64s-2.8 43.5-8.1 64H380.8c2.1-20.6 3.2-42 3.2-64s-1.1-43.4-3.2-64zm112.6-32H376.7c-10-63.9-29.8-117.4-55.3-151.6c78.3 20.7 142 77.5 171.9 151.6zm-149.1 0H167.7c6.1-36.4 15.5-68.6 27-94.7c10.5-23.6 22.2-40.7 33.5-51.5C239.4 3.2 248.7 0 256 0s16.6 3.2 27.8 13.8c11.3 10.8 23 27.9 33.5 51.5c11.6 26 20.9 58.2 27 94.7zm-209 0H18.6C48.6 85.9 112.2 29.1 190.6 8.4C165.1 42.6 145.3 96.1 135.3 160zM8.1 192H131.2c-2.1 20.6-3.2 42-3.2 64s1.1 43.4 3.2 64H8.1C2.8 299.5 0 278.1 0 256s2.8-43.5 8.1-64zM194.7 446.6c-11.6-26-20.9-58.2-27-94.6H344.3c-6.1 36.4-15.5 68.6-27 94.6c-10.5 23.6-22.2 40.7-33.5 51.5C272.6 508.8 263.3 512 256 512s-16.6-3.2-27.8-13.8c-11.3-10.8-23-27.9-33.5-51.5zM135.3 352c10 63.9 29.8 117.4 55.3 151.6C112.2 482.9 48.6 426.1 18.6 352H135.3zm358.1 0c-30 74.1-93.6 130.9-171.9 151.6c25.5-34.2 45.2-87.7 55.3-151.6H493.4z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<!-- Source: Font Awesome 6 --><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M504.3 273.6c4.9-4.5 7.7-10.9 7.7-17.6s-2.8-13-7.7-17.6l-112-104c-7-6.5-17.2-8.2-25.9-4.4s-14.4 12.5-14.4 22l0 56-192 0 0-56c0-9.5-5.7-18.2-14.4-22s-18.9-2.1-25.9 4.4l-112 104C2.8 243 0 249.3 0 256s2.8 13 7.7 17.6l112 104c7 6.5 17.2 8.2 25.9 4.4s14.4-12.5 14.4-22l0-56 192 0 0 56c0 9.5 5.7 18.2 14.4 22s18.9 2.1 25.9-4.4l112-104z"/></svg>

After

Width:  |  Height:  |  Size: 440 B

View File

@@ -0,0 +1 @@
<!-- Source: Font Awesome 6 --><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM208 352a144 144 0 1 0 0-288 144 144 0 1 0 0 288z"/></svg>

After

Width:  |  Height:  |  Size: 343 B

View File

@@ -0,0 +1 @@
<!-- Source: Font Awesome 6 --><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80V432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z"/></svg>

After

Width:  |  Height:  |  Size: 257 B

View File

@@ -0,0 +1 @@
<!-- Source: Font Awesome 6 --><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M0 80V229.5c0 17 6.7 33.3 18.7 45.3l176 176c25 25 65.5 25 90.5 0L418.7 317.3c25-25 25-65.5 0-90.5l-176-176c-12-12-28.3-18.7-45.3-18.7H48C21.5 32 0 53.5 0 80zm112 32a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"/></svg>

After

Width:  |  Height:  |  Size: 310 B

View File

@@ -0,0 +1 @@
<!-- Source: Font Awesome 6 --><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M224 16c-6.7 0-10.8-2.8-15.5-6.1C201.9 5.4 194 0 176 0c-30.5 0-52 43.7-66 89.4C62.7 98.1 32 112.2 32 128c0 14.3 25 27.1 64.6 35.9c-.4 4-.6 8-.6 12.1c0 17 3.3 33.2 9.3 48H45.4C38 224 32 230 32 237.4c0 1.7 .3 3.4 1 5l38.8 96.9C28.2 371.8 0 423.8 0 482.3C0 498.7 13.3 512 29.7 512H418.3c16.4 0 29.7-13.3 29.7-29.7c0-58.5-28.2-110.4-71.7-143L415 242.4c.6-1.6 1-3.3 1-5c0-7.4-6-13.4-13.4-13.4H342.7c6-14.8 9.3-31 9.3-48c0-4.1-.2-8.1-.6-12.1C391 155.1 416 142.3 416 128c0-15.8-30.7-29.9-78-38.6C324 43.7 302.5 0 272 0c-18 0-25.9 5.4-32.5 9.9c-4.8 3.3-8.8 6.1-15.5 6.1zm56 208H267.6c-16.5 0-31.1-10.6-36.3-26.2c-2.3-7-12.2-7-14.5 0c-5.2 15.6-19.9 26.2-36.3 26.2H168c-22.1 0-40-17.9-40-40V169.6c28.2 4.1 61 6.4 96 6.4s67.8-2.3 96-6.4V184c0 22.1-17.9 40-40 40zm-88 96l16 32L176 480 128 288l64 32zm128-32L272 480 240 352l16-32 64-32z"/></svg>

After

Width:  |  Height:  |  Size: 934 B

View File

@@ -0,0 +1 @@
<!-- Source: Font Awesome 6 --><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"/></svg>

After

Width:  |  Height:  |  Size: 390 B

View File

@@ -1,4 +1,3 @@
import { IconBootstrapper } from './Modules/IconBootstrapper';
import { VueConstructor, IVueBootstrapper } from './IVueBootstrapper';
import { VueBootstrapper } from './Modules/VueBootstrapper';
import { RuntimeSanityValidator } from './Modules/RuntimeSanityValidator';
@@ -14,7 +13,6 @@ export class ApplicationBootstrapper implements IVueBootstrapper {
private static getAllBootstrappers(): IVueBootstrapper[] {
return [
new IconBootstrapper(),
new VueBootstrapper(),
new RuntimeSanityValidator(),
new AppInitializationLogger(),

View File

@@ -1,38 +0,0 @@
import { library } from '@fortawesome/fontawesome-svg-core';
import { faGithub } from '@fortawesome/free-brands-svg-icons';
/** BRAND ICONS (PREFIX: fab) */
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
/** REGULAR ICONS (PREFIX: far) */
import { faFolderOpen, faFolder, faSmile } from '@fortawesome/free-regular-svg-icons';
/** SOLID ICONS (PREFIX: fas (default)) */
import {
faTimes, faFileDownload, faCopy, faSearch, faInfoCircle, faUserSecret, faDesktop, faTag, faGlobe,
faSave, faBatteryFull, faBatteryHalf, faPlay, faArrowsAltH,
} from '@fortawesome/free-solid-svg-icons';
import { IVueBootstrapper, VueConstructor } from '../IVueBootstrapper';
export class IconBootstrapper implements IVueBootstrapper {
public bootstrap(vue: VueConstructor): void {
library.add(
faGithub,
faUserSecret,
faSmile,
faDesktop,
faGlobe,
faTag,
faFolderOpen,
faFolder,
faTimes,
faFileDownload,
faSave,
faCopy,
faPlay,
faSearch,
faBatteryFull,
faBatteryHalf,
faInfoCircle,
faArrowsAltH,
);
vue.component('font-awesome-icon', FontAwesomeIcon);
}
}

View File

@@ -4,30 +4,30 @@
type="button"
@click="onClicked"
>
<font-awesome-icon
<AppIcon
class="button__icon"
:icon="[iconPrefix, iconName]"
size="2x"
:icon="iconName"
/>
<div class="button__text">{{text}}</div>
</button>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { defineComponent, PropType } from 'vue';
import { IconName } from '@/presentation/components/Shared/Icon/IconName';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
export default defineComponent({
components: {
AppIcon,
},
props: {
text: {
type: String,
required: true,
},
iconPrefix: {
type: String,
required: true,
},
iconName: {
type: String,
type: String as PropType<IconName>,
required: true,
},
},
@@ -64,6 +64,10 @@ export default defineComponent({
box-shadow: 0 3px 9px $color-primary-darkest;
border-radius: 4px;
&__icon {
font-size: 2em;
}
@include clickable;
width: 10%;

View File

@@ -3,9 +3,9 @@
<span class="dollar">$</span>
<code><slot /></code>
<TooltipWrapper>
<font-awesome-icon
<AppIcon
class="copy-button"
:icon="['fas', 'copy']"
icon="copy"
@click="copyCode"
/>
<template v-slot:tooltip>
@@ -19,10 +19,12 @@
import { defineComponent, useSlots } from 'vue';
import { Clipboard } from '@/infrastructure/Clipboard';
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
export default defineComponent({
components: {
TooltipWrapper,
AppIcon,
},
setup() {
const slots = useSlots();

View File

@@ -14,7 +14,7 @@
<p>
<strong>2. The hard (manual) alternative</strong>. This requires you to do additional manual
steps. If you are unsure how to follow the instructions, hover on information
(<font-awesome-icon :icon="['fas', 'info-circle']" />)
(<AppIcon icon="circle-info" />)
icons near the steps, or follow the easy alternative described above.
</p>
<p>
@@ -27,9 +27,9 @@
<div class="step__action">
<span>{{ step.action.instruction }}</span>
<TooltipWrapper v-if="step.action.details">
<font-awesome-icon
<AppIcon
class="explanation"
:icon="['fas', 'info-circle']"
icon="circle-info"
/>
<template v-slot:tooltip>
<div v-html="step.action.details" />
@@ -39,9 +39,9 @@
<div v-if="step.code" class="step__code">
<CodeInstruction>{{ step.code.instruction }}</CodeInstruction>
<TooltipWrapper v-if="step.code.details">
<font-awesome-icon
<AppIcon
class="explanation"
:icon="['fas', 'info-circle']"
icon="circle-info"
/>
<template v-slot:tooltip>
<div v-html="step.code.details" />
@@ -62,6 +62,7 @@ import {
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { OperatingSystem } from '@/domain/OperatingSystem';
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import CodeInstruction from './CodeInstruction.vue';
import { IInstructionListData } from './InstructionListData';
@@ -69,6 +70,7 @@ export default defineComponent({
components: {
CodeInstruction,
TooltipWrapper,
AppIcon,
},
props: {
data: {

View File

@@ -4,19 +4,16 @@
v-if="canRun"
text="Run"
v-on:click="executeCode"
icon-prefix="fas"
icon-name="play"
/>
<IconButton
:text="isDesktopVersion ? 'Save' : 'Download'"
v-on:click="saveCode"
icon-prefix="fas"
:icon-name="isDesktopVersion ? 'save' : 'file-download'"
:icon-name="isDesktopVersion ? 'floppy-disk' : 'file-arrow-down'"
/>
<IconButton
text="Copy"
v-on:click="copyCode"
icon-prefix="fas"
icon-name="copy"
/>
<ModalDialog v-if="instructions" v-model="areInstructionsVisible">

View File

@@ -4,9 +4,9 @@
:style="{ cursor: cursorCssValue }"
@mousedown="startResize">
<div class="line" />
<font-awesome-icon
<AppIcon
class="icon"
:icon="['fas', 'arrows-alt-h']"
icon="left-right"
/>
<div class="line" />
</div>
@@ -14,8 +14,12 @@
<script lang="ts">
import { defineComponent, onUnmounted } from 'vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
export default defineComponent({
components: {
AppIcon,
},
emits: {
/* eslint-disable @typescript-eslint/no-unused-vars */
resized: (displacementX: number) => true,

View File

@@ -17,18 +17,18 @@
</span>
<span v-else>Oh no 😢</span>
<!-- Expand icon -->
<font-awesome-icon
<AppIcon
class="card__inner__expand-icon"
:icon="['far', isExpanded ? 'folder-open' : 'folder']"
:icon="isExpanded ? 'folder-open' : 'folder'"
/>
<!-- Indeterminate and full states -->
<div class="card__inner__state-icons">
<font-awesome-icon
:icon="['fa', 'battery-half']"
<AppIcon
icon="battery-half"
v-if="isAnyChildSelected && !areAllChildrenSelected"
/>
<font-awesome-icon
:icon="['fa', 'battery-full']"
<AppIcon
icon="battery-full"
v-if="areAllChildrenSelected"
/>
</div>
@@ -38,8 +38,8 @@
<ScriptsTree :categoryId="categoryId" />
</div>
<div class="card__expander__close-button">
<font-awesome-icon
:icon="['fas', 'times']"
<AppIcon
icon="xmark"
v-on:click="collapse()"
/>
</div>
@@ -52,6 +52,7 @@ import {
defineComponent, ref, watch, computed,
inject,
} from 'vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue';
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
@@ -59,6 +60,7 @@ import { sleep } from '@/infrastructure/Threading/AsyncSleep';
export default defineComponent({
components: {
ScriptsTree,
AppIcon,
},
props: {
categoryId: {

View File

@@ -18,7 +18,7 @@
class="search__query__close-button"
v-on:click="clearSearchQuery()"
>
<font-awesome-icon :icon="['fas', 'times']" />
<AppIcon icon="xmark" />
</div>
</div>
<div v-if="!searchHasMatches" class="search-no-matches">
@@ -41,6 +41,7 @@ import {
defineComponent, PropType, ref, computed,
inject,
} from 'vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue';
import CardList from '@/presentation/components/Scripts/View/Cards/CardList.vue';
@@ -52,6 +53,7 @@ export default defineComponent({
components: {
ScriptsTree,
CardList,
AppIcon,
},
props: {
currentView: {

View File

@@ -6,14 +6,18 @@
v-on:click.stop
v-on:click="toggle()"
>
<font-awesome-icon :icon="['fas', 'info-circle']" />
<AppIcon icon="circle-info" />
</a>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
export default defineComponent({
components: {
AppIcon,
},
emits: [
'show',
'hide',
@@ -52,5 +56,4 @@ export default defineComponent({
color: $color-primary-light;
}
}
</style>

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 */

View File

@@ -6,7 +6,7 @@
'container-supported': hasCurrentOsDesktopVersion,
}">
<span class="description">
<font-awesome-icon class="description__icon" :icon="['fas', 'desktop']" />
<AppIcon class="description__icon" icon="desktop" />
<span class="description__text">For desktop:</span>
</span>
<span class="urls">
@@ -21,6 +21,7 @@
import { defineComponent, inject } from 'vue';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import DownloadUrlListItem from './DownloadUrlListItem.vue';
const supportedOperativeSystems: readonly OperatingSystem[] = [
@@ -32,6 +33,7 @@ const supportedOperativeSystems: readonly OperatingSystem[] = [
export default defineComponent({
components: {
DownloadUrlListItem,
AppIcon,
},
setup() {
const { os: currentOs } = inject(InjectionKeys.useRuntimeEnvironment);

View File

@@ -3,7 +3,7 @@
<div class="footer">
<div class="footer__section">
<span v-if="isDesktop" class="footer__section__item">
<font-awesome-icon class="icon" :icon="['fas', 'globe']" />
<AppIcon class="icon" icon="globe" />
<span>
Online version at <a :href="homepageUrl" target="_blank" rel="noopener noreferrer">{{ homepageUrl }}</a>
</span>
@@ -15,24 +15,24 @@
<div class="footer__section">
<div class="footer__section__item">
<a :href="feedbackUrl" target="_blank" rel="noopener noreferrer">
<font-awesome-icon class="icon" :icon="['far', 'smile']" />
<AppIcon class="icon" icon="face-smile" />
<span>Feedback</span>
</a>
</div>
<div class="footer__section__item">
<a :href="repositoryUrl" target="_blank" rel="noopener noreferrer">
<font-awesome-icon class="icon" :icon="['fab', 'github']" />
<AppIcon class="icon" icon="github" />
<span>Source Code</span>
</a>
</div>
<div class="footer__section__item">
<a :href="releaseUrl" target="_blank" rel="noopener noreferrer">
<font-awesome-icon class="icon" :icon="['fas', 'tag']" />
<AppIcon class="icon" icon="tag" />
<span>v{{ version }}</span>
</a>
</div>
<div class="footer__section__item">
<font-awesome-icon class="icon" :icon="['fas', 'user-secret']" />
<AppIcon class="icon" icon="user-secret" />
<a @click="showPrivacyDialog()">Privacy</a>
</div>
</div>
@@ -48,6 +48,7 @@ import {
defineComponent, ref, computed, inject,
} from 'vue';
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import DownloadUrlList from './DownloadUrlList.vue';
import PrivacyPolicy from './PrivacyPolicy.vue';
@@ -57,6 +58,7 @@ export default defineComponent({
ModalDialog,
PrivacyPolicy,
DownloadUrlList,
AppIcon,
},
setup() {
const { info } = inject(InjectionKeys.useApplication);

View File

@@ -7,7 +7,7 @@
v-model="searchQuery"
>
<div class="icon-wrapper">
<font-awesome-icon :icon="['fas', 'search']" />
<AppIcon icon="magnifying-glass" />
</div>
</div>
</template>
@@ -19,11 +19,13 @@ import {
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
export default defineComponent({
components: { AppIcon },
directives: {
NonCollapsing,
},

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