diff --git a/jest.config.js b/jest.config.js index 5368a210..c61b6406 100644 --- a/jest.config.js +++ b/jest.config.js @@ -25,6 +25,9 @@ module.exports = { '/node_modules/', ...getIgnorePattern(process.env.TEST_ENV), ], + moduleNameMapper: { + '\\.(svg|png|jpg|gif|css)$': '/tests/mocks/image-import.mock.js', + }, }; function buildWebsite(env) { diff --git a/package-lock.json b/package-lock.json index 8bc55c6a..18d0ed78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2437,15 +2437,6 @@ "postcss-value-parser": "^4.2.0" } }, - "axios": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.25.0.tgz", - "integrity": "sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==", - "dev": true, - "requires": { - "follow-redirects": "^1.14.7" - } - }, "babel-jest": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-27.5.1.tgz", @@ -2839,6 +2830,16 @@ "get-intrinsic": "^1.0.2" } }, + "call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "requires": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + } + }, "callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3685,6 +3686,17 @@ "tslib": "^2.0.3" } }, + "dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "requires": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + } + }, "eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -3779,12 +3791,33 @@ "unbox-primitive": "^1.0.1" } }, + "es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true + }, + "es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true + }, "es-module-lexer": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz", "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==", "dev": true }, + "es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "requires": { + "es-errors": "^1.3.0" + } + }, "es-to-primitive": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", @@ -4249,9 +4282,9 @@ "optional": true }, "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true }, "gensync": { @@ -4267,14 +4300,21 @@ "dev": true }, "get-intrinsic": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", - "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, "requires": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.1" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" } }, "get-package-type": { @@ -4283,6 +4323,16 @@ "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "dev": true }, + "get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "requires": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + } + }, "get-relative-luminance": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/get-relative-luminance/-/get-relative-luminance-1.0.0.tgz", @@ -4299,15 +4349,15 @@ "dev": true }, "glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "dev": true, "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", - "minimatch": "^3.0.4", + "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } @@ -4377,6 +4427,12 @@ } } }, + "gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true + }, "graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", @@ -4405,11 +4461,20 @@ "dev": true }, "has-symbols": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", - "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true }, + "hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "requires": { + "function-bind": "^1.1.2" + } + }, "he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -6603,6 +6668,12 @@ "tmpl": "1.0.5" } }, + "math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true + }, "mdn-data": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", @@ -7835,9 +7906,9 @@ "dev": true }, "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "requires": { "inherits": "^2.0.3", @@ -7959,6 +8030,11 @@ "strip-ansi": "^6.0.1" } }, + "request-animation-frame-timeout": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/request-animation-frame-timeout/-/request-animation-frame-timeout-2.0.4.tgz", + "integrity": "sha512-5oYwRBYjrMSU/YHHXj5AM/nv96ZE0b8WZoA3FqnkeDDPXoprxUCZFK4IWZTl+y3RJQtaihiJPiKOB4NZfZ7C7A==" + }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -8866,6 +8942,14 @@ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", "dev": true }, + "virtual-scroller": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/virtual-scroller/-/virtual-scroller-1.13.1.tgz", + "integrity": "sha512-sui46QUBOIfHyXYjdGkxoze/GlCZFUFRxzxEvsu06UQ4iPc3uRfGnm/Qj7195hiMVOYQW9lDn+m3sD7sRMYdYg==", + "requires": { + "request-animation-frame-timeout": "^2.0.3" + } + }, "void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -8901,6 +8985,17 @@ "lodash": "^4.17.21", "minimist": "^1.2.5", "rxjs": "^7.5.4" + }, + "dependencies": { + "axios": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.25.0.tgz", + "integrity": "sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==", + "dev": true, + "requires": { + "follow-redirects": "^1.14.7" + } + } } }, "walker": { diff --git a/package.json b/package.json index 7b1e52f9..ffc72ae2 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "node": ">=16.0.0" }, "dependencies": { - "simple-icons": "15.11.0" + "simple-icons": "15.11.0", + "virtual-scroller": "1.13.1" } } diff --git a/public/images/Ellipsis@1x-1.0s-200px-200px.svg b/public/images/Ellipsis@1x-1.0s-200px-200px.svg new file mode 100644 index 00000000..23488b29 --- /dev/null +++ b/public/images/Ellipsis@1x-1.0s-200px-200px.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/index.pug b/public/index.pug index 718dcb9a..83f74b46 100644 --- a/public/index.pug +++ b/public/index.pug @@ -31,7 +31,7 @@ html(lang='en-US') link(rel='canonical', href=pageUrl) // prettier-ignore script(defer data-cf-beacon='{"token": "ccba8f8122264870957c359fdfe4f626"}' src="https://static.cloudflareinsights.com/beacon.min.js") - + body.no-js.order-alphabetically(tabindex='-1') header.header ul.header__list @@ -50,7 +50,7 @@ html(lang='en-US') path( d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12' ) - li.header__list-item + li.header__list-item a.header__list-icon( title='SimpleIcons', href='https://simpleicons.org/', @@ -201,63 +201,77 @@ html(lang='en-US') d='M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243L6.586 4.672z' ) - ul.grid - li.grid-item--if-empty.hidden(aria-hidden='true') + #loader.hidden + img( + src='/images/Ellipsis@1x-1.0s-200px-200px.svg' + height="150" + alt='loader' + ) + script. + document.getElementById('loader').classList.remove('hidden'); + + div.grid-rows-container + div.grid-item--if-empty.hidden(aria-hidden='true') p Icon missing? #[a(href='https://github.com/simple-icons/simple-icons/issues/new?labels=new+icon&template=icon_request.yml&title=Request%3A+', rel='noopener', target='_blank') Submit a request] + div#virtual-list.grid-rows-container - each icon in icons - li.grid-item( - style=`--order-color: ${icon.indexByColor}`, - data-brand=`${icon.normalizedName}`, - data-title=`${icon.title}`, - data-encoded_title=`${icon.badgeEncodedTitle}`, - data-color=`${icon.shortHex}`, - data-logo_color=`${icon.superLight ? '000' : 'fff'}`, - data-slug=`${icon.slug}` - ) - .grid-item__row.mv-2.fg-2 - button.grid-item__preview.copy-button.copy-svg( - title=`${icon.title} SVG`, - data-style='for-the-badge', - disabled - ) - img.icon-preview( - src=`https://img.shields.io/badge/${icon.badgeEncodedTitle}-${icon.shortHex}?logo=${icon.slug}&logoColor=${icon.superLight ? '000' : 'fff'}&style=for-the-badge`, - loading='lazy', - alt=`${icon.title} badge` + noscript + ul.grid + each icon in icons + li.grid-item( + style=`--order-color: ${ icon.indexByColor }`, + data-brand=`${ icon.normalizedName }`, + data-title=`${ icon.title }`, + data-encoded_title=`${ icon.badgeEncodedTitle }`, + data-color=`${ icon.shortHex }`, + data-logo_color=`${ icon.superLight ? '000' : 'fff' }`, + data-slug=`${ icon.slug }` + ) + .grid-item__row.mv-2.fg-2 + button.grid-item__preview.copy-button.copy-svg( + title=`${ icon.title } SVG`, + data-style='for-the-badge', + disabled ) - .grid-item__row - if icon.guidelines - a.grid-item__link.link-button( - title=`${icon.title} guidelines`, - href=icon.guidelines, - rel='noopener', - target='_blank' - ) Brand Guidelines - if icon.license - a.grid-item__link.link-button.icon-legal( - title=`${icon.title} icon license`, - href=icon.license.url, - rel='noopener', - target='_blank' - ) #{ icon.license.type } - h2.grid-item__title #{ icon.title } - .grid-item__footer - button.grid-item__color.copy-button.copy-color( - title=`${icon.title} Flat Badge`, - data-style='flat', - disabled - ) Flat - button.grid-item__color.copy-button.copy-color( - title=`${icon.title} Square Badge`, - data-style='flat-square', - disabled - ) Square - button.grid-item__color.copy-button.copy-color( - title=`${icon.title} Plastic Badge`, - data-style='plastic', - disabled - ) Plastic + img.icon-preview( + src=`https://img.shields.io/badge/${ icon.badgeEncodedTitle }-${ icon.shortHex }?logo=${ icon.slug }&logoColor=${ icon.superLight + ? '000' + : 'fff' }&style=for-the-badge`, + loading='lazy', + alt=`${ icon.title } badge` + ) + .grid-item__row + if icon.guidelines + a.grid-item__link.link-button( + title=`${ icon.title } guidelines`, + href=icon.guidelines, + rel='noopener', + target='_blank' + ) Brand Guidelines + if icon.license + a.grid-item__link.link-button.icon-legal( + title=`${ icon.title } icon license`, + href=icon.license.url, + rel='noopener', + target='_blank' + ) #{ icon.license.type } + h2.grid-item__title #{ icon.title } + .grid-item__footer + button.grid-item__color.copy-button.copy-color( + title=`${ icon.title } Flat Badge`, + data-style='flat', + disabled + ) Flat + button.grid-item__color.copy-button.copy-color( + title=`${ icon.title } Square Badge`, + data-style='flat-square', + disabled + ) Square + button.grid-item__color.copy-button.copy-color( + title=`${ icon.title } Plastic Badge`, + data-style='plastic', + disabled + ) Plastic footer.footer .footer-description diff --git a/public/scripts/copy.js b/public/scripts/copy.js index f808230b..dad1e702 100644 --- a/public/scripts/copy.js +++ b/public/scripts/copy.js @@ -48,40 +48,42 @@ export default function initCopyButtons(window, document, navigator, storage) { // Init icon copy buttons const $copyInput = document.getElementById('copy-input'); - const $copyButtons = document.querySelectorAll('.copy-button'); - - $copyButtons.forEach(($copyButton) => { - $copyButton.removeAttribute('disabled'); - $copyButton.addEventListener('click', (event) => { - event.preventDefault(); - - const $parentListItem = $copyButton.closest('li'); - const iconEncodedTitle = $parentListItem.dataset.encoded_title; - const iconLogoColor = $parentListItem.dataset.logo_color; - const iconTitle = $parentListItem.dataset.title; - const iconColor = $parentListItem.dataset.color; - const iconSlug = $parentListItem.dataset.slug; - const iconStyle = $copyButton.dataset.style; - const badgeURL = - SHIELDS_BASE_URL + - `/badge/${iconEncodedTitle}-${iconColor}?logo=${iconSlug}&logoColor=${iconLogoColor}&style=${iconStyle}`; - - let value; - switch (activeCopyMethod) { - case COPY_AS_MARKDOWN: - value = `![${iconTitle} Badge](${badgeURL})`; - break; - case COPY_AS_HTML: - value = `${iconTitle} Badge`; - break; - case COPY_AS_LINK: - value = badgeURL; - break; - } - - copyValue(value); - setCopied($copyButton); - }); + const $virtualList = document.getElementById('virtual-list'); + + $virtualList.addEventListener('click', (event) => { + event.preventDefault(); + + const $copyButton = event.target.closest('.copy-button'); + + if (!$copyButton) return; + + const $parentListItem = event.target.closest('.grid-item'); + + const iconEncodedTitle = $parentListItem.dataset.encoded_title; + const iconLogoColor = $parentListItem.dataset.logo_color; + const iconTitle = $parentListItem.dataset.title; + const iconColor = $parentListItem.dataset.color; + const iconSlug = $parentListItem.dataset.slug; + const iconStyle = $copyButton.dataset.style; + const badgeURL = + SHIELDS_BASE_URL + + `/badge/${iconEncodedTitle}-${iconColor}?logo=${iconSlug}&logoColor=${iconLogoColor}&style=${iconStyle}`; + + let value; + switch (activeCopyMethod) { + case COPY_AS_MARKDOWN: + value = `![${iconTitle} Badge](${badgeURL})`; + break; + case COPY_AS_HTML: + value = `${iconTitle} Badge`; + break; + case COPY_AS_LINK: + value = badgeURL; + break; + } + + copyValue(value); + setCopied($copyButton); }); function copyValue(value) { diff --git a/public/scripts/dom-utils.js b/public/scripts/dom-utils.js index 7d588d16..88501295 100644 --- a/public/scripts/dom-utils.js +++ b/public/scripts/dom-utils.js @@ -29,3 +29,25 @@ export function toggleClass($el, clazz) { $el.classList.toggle(clazz); } } + +export function getColumnsCount($container) { + const rootFontSize = parseFloat( + window.getComputedStyle(document.documentElement).fontSize, + ); + const computedStyle = window.getComputedStyle($container); + + const minColumnWidthRem = + parseFloat(computedStyle.getPropertyValue('--grid-width')) || 13.5; + const gapRem = + parseFloat(computedStyle.getPropertyValue('--grid-gap')) || 0.75; + + const minColumnWidthPx = minColumnWidthRem * rootFontSize; + const gapPx = gapRem * rootFontSize; + + const containerWidth = $container.clientWidth; + const count = Math.floor( + (containerWidth + gapPx) / (minColumnWidthPx + gapPx), + ); + + return Math.max(1, count); +} diff --git a/public/scripts/icons.js b/public/scripts/icons.js new file mode 100644 index 00000000..4a788d47 --- /dev/null +++ b/public/scripts/icons.js @@ -0,0 +1,116 @@ +import EllipseLoader from '../images/Ellipsis@1x-1.0s-200px-200px.svg'; + +export const imageCache = new Map(); + +export function createListElement(icon) { + const baseUrl = 'https://img.shields.io/badge/'; + const badgeUrl = new URL( + `${icon.badgeEncodedTitle}-${icon.shortHex}`, + baseUrl, + ); + badgeUrl.searchParams.set('logo', icon.slug); + badgeUrl.searchParams.set('logoColor', icon.superLight ? '000' : 'fff'); + badgeUrl.searchParams.set('style', 'for-the-badge'); + + const remoteImageUrl = badgeUrl.href; + const cachedImage = imageCache.get(remoteImageUrl); + const initialSrc = cachedImage || EllipseLoader; + + const guidelinesHtml = icon.guidelines + ? ` + + Brand Guidelines + + ` + : ''; + + const licenseHtml = icon.license + ? ` + + ${icon.license.type} + + ` + : ''; + + const listItemHtml = ` +
+ +
+ +
+ +
+ ${guidelinesHtml} + ${licenseHtml} +

${icon.title}

+
+ + +
+ `; + + const $temp = document.createElement('div'); + $temp.innerHTML = listItemHtml.trim(); + + const $image = $temp.querySelector('.icon-preview'); + + if (!cachedImage) { + fetch(remoteImageUrl) + .then((response) => { + if (!response.ok) { + throw new Error('Network response was not ok'); + } + return response.blob(); + }) + .then((imageBlob) => { + const objectUrl = URL.createObjectURL(imageBlob); + imageCache.set(remoteImageUrl, objectUrl); + $image.src = objectUrl; + }) + .catch((error) => { + console.error('There was a problem with the fetch operation:', error); + }); + } + + return $temp.firstElementChild; +} diff --git a/public/scripts/index.js b/public/scripts/index.js index f6e586c7..b3207e41 100644 --- a/public/scripts/index.js +++ b/public/scripts/index.js @@ -1,18 +1,53 @@ import '../stylesheet.css'; import * as domUtils from './dom-utils.js'; +import { groupIntoRows } from './utils.js'; import newStorage from './storage.js'; +import initVirtualWindow from './virtual-window.js'; import initCopyButtons from './copy.js'; import initColorScheme from './color-scheme.js'; import initOrdering from './ordering.js'; import initSearch from './search.js'; -console.log('Build #DEVELOPMENT_BUILD#'); +if (process.env.NODE_ENV === 'development') { + console.log('Build #DEVELOPMENT_BUILD#'); +} document.body.classList.remove('no-js'); +const $loader = document.getElementById('loader'); +if ($loader) $loader.remove(); + +let currentBadges = ICONS_DATA; const storage = newStorage(localStorage); + initColorScheme(document, storage); initCopyButtons(window, document, navigator, storage); -const orderingControls = initOrdering(document, storage); -initSearch(window.history, document, orderingControls, domUtils); +const { scroller, virtualWindowContainer } = initVirtualWindow( + window, + document, + () => currentBadges, +); +const orderingControls = initOrdering( + document, + storage, + () => currentBadges, + (sortedData) => { + const columnsCount = domUtils.getColumnsCount(virtualWindowContainer); + currentBadges = sortedData; + scroller.setItems(groupIntoRows(sortedData, columnsCount)); + }, +); +initSearch( + window.history, + document, + orderingControls, + domUtils, + ICONS_DATA, + (results) => { + const columnsCount = domUtils.getColumnsCount(virtualWindowContainer); + const sortedData = orderingControls.sortBadges(results); + currentBadges = sortedData; + scroller.setItems(groupIntoRows(sortedData, columnsCount)); + }, +); diff --git a/public/scripts/ordering.js b/public/scripts/ordering.js index 3af1e28e..f8cc26fa 100644 --- a/public/scripts/ordering.js +++ b/public/scripts/ordering.js @@ -10,7 +10,12 @@ const CLASS_ORDER_ALPHABETICALLY = 'order-alphabetically'; const CLASS_ORDER_BY_COLOR = 'order-by-color'; const CLASS_ORDER_BY_RELEVANCE = 'order-by-relevance'; -export default function initOrdering(document, storage) { +export default function initOrdering( + document, + storage, + getBadges, + onOrderChange, +) { let activeOrdering = DEFAULT_ORDERING; let preferredOrdering = DEFAULT_ORDERING; @@ -70,15 +75,43 @@ export default function initOrdering(document, storage) { } activeOrdering = selected; + + if ( + typeof getBadges === 'function' && + typeof onOrderChange === 'function' + ) { + const sortedData = sortBadges(getBadges(), activeOrdering); + onOrderChange(sortedData); + } } function resetOrdering() { return selectOrdering(preferredOrdering); } + function sortBadges(items, orderType) { + if (!items || !items.length) { + return []; + } + + const ordering = orderType || activeOrdering; + const sorted = [...items]; + + if (ordering === ORDER_ALPHABETICALLY) { + sorted.sort((a, b) => a.indexByAlpha - b.indexByAlpha); + } else if (ordering === ORDER_BY_COLOR) { + sorted.sort((a, b) => a.indexByColor - b.indexByColor); + } else if (ordering === ORDER_BY_RELEVANCE) { + sorted.sort((a, b) => a.relevanceScore - b.relevanceScore); + } + + return sorted; + } + return { currentOrderingIs, selectOrdering, resetOrdering, + sortBadges, }; } diff --git a/public/scripts/search.js b/public/scripts/search.js index 6fa4d618..15cf2786 100644 --- a/public/scripts/search.js +++ b/public/scripts/search.js @@ -42,7 +42,14 @@ function setSearchQueryInURL(history, path, query) { } } -export default function initSearch(history, document, ordering, domUtils) { +export default function initSearch( + history, + document, + ordering, + domUtils, + badges, + onSearch, +) { let activeQuery = ''; const $searchInput = document.getElementById('search-input'); @@ -97,21 +104,13 @@ export default function initSearch(history, document, ordering, domUtils) { } } - let noResults = true; - $icons.forEach(($icon) => { - const brandName = $icon.getAttribute('data-brand'); - const score = getScore(query, brandName); - if (score < 0) { - $icon.style.removeProperty('--order-relevance'); - domUtils.hideElement($icon); - } else { - $icon.style.setProperty('--order-relevance', score); - domUtils.showElement($icon); - noResults = false; - } - }); + const results = searchBadges(badges, query); + + if (typeof onSearch === 'function') { + onSearch(results, query); + } - if (noResults) { + if (results.length === 0) { domUtils.showElement($gridItemIfEmpty); } else { domUtils.hideElement($gridItemIfEmpty); @@ -119,4 +118,23 @@ export default function initSearch(history, document, ordering, domUtils) { activeQuery = query; } + + function searchBadges(items, query) { + if (!items || !items.length) { + return []; + } + + if (!query) { + return [...items]; + } + + const results = items + .map((item) => { + const score = getScore(query, item.normalizedName); + return { ...item, relevanceScore: score }; + }) + .filter((item) => item.relevanceScore >= 0); + + return results; + } } diff --git a/public/scripts/utils.js b/public/scripts/utils.js index cbf56573..f25cc96f 100644 --- a/public/scripts/utils.js +++ b/public/scripts/utils.js @@ -44,4 +44,11 @@ module.exports = { .normalize('NFD') .replace(/[\u0300-\u036f]/g, ''); }, + groupIntoRows: function (items, columnsCount) { + const rows = []; + for (let i = 0; i < items.length; i += columnsCount) { + rows.push(items.slice(i, i + columnsCount)); + } + return rows; + }, }; diff --git a/public/scripts/virtual-window.js b/public/scripts/virtual-window.js new file mode 100644 index 00000000..a133c073 --- /dev/null +++ b/public/scripts/virtual-window.js @@ -0,0 +1,53 @@ +import VirtualScroller from 'virtual-scroller/dom'; +import { createListElement } from './icons.js'; +import { getColumnsCount } from './dom-utils.js'; +import { groupIntoRows } from './utils.js'; + +export default function initVirtualWindow(window, document, getBadges) { + const $container = document.getElementById('virtual-list'); + + if (!$container) { + throw new Error('Virtual window element not found.'); + } + + let columnsCount = getColumnsCount($container); + const itemsRows = groupIntoRows(getBadges(), columnsCount); + const scroller = initScroller($container, itemsRows); + + function updateColumnsCount() { + const newColumnsCount = getColumnsCount($container); + + if (newColumnsCount !== columnsCount) { + scroller.setItems(groupIntoRows(getBadges(), newColumnsCount)); + columnsCount = newColumnsCount; + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', updateColumnsCount); + } else { + updateColumnsCount(); + } + window.addEventListener('resize', updateColumnsCount); + + return { + scroller, + virtualWindowContainer: $container, + }; +} + +function initScroller($container, items) { + function renderItem(row) { + const $rowElement = document.createElement('div'); + $rowElement.className = 'grid'; + + row.forEach((item) => { + const $listElement = createListElement(item); + $rowElement.appendChild($listElement); + }); + + return $rowElement; + } + + return new VirtualScroller($container, items, renderItem); +} diff --git a/public/stylesheet.css b/public/stylesheet.css index 79cbc0a6..9a6cbb3b 100644 --- a/public/stylesheet.css +++ b/public/stylesheet.css @@ -67,6 +67,9 @@ --lm-color-link: rgb(0, 0, 238); --lm-color-link-hover: rgb(52, 52, 238); --lm-color-link-visited: rgb(85, 26, 139); + + --grid-width: 13.5rem; + --grid-gap: 0.75rem; } @media (max-width: 1280px) { :root { @@ -475,11 +478,15 @@ body.order-by-relevance #order-relevance { /* Grid */ -.grid { - --grid-width: 13.5rem; +.grid-rows-container { + .grid { + margin: 0 0 var(--grid-gap); + } +} +.grid { display: grid; - gap: 0.75rem; + gap: var(--grid-gap); grid-template-columns: repeat(auto-fill, minmax(var(--grid-width), 1fr)); margin: 0 0 1.5rem; } @@ -633,15 +640,6 @@ body.order-by-relevance #order-relevance { color: var(--color-button-text-hover); } -/* Ordering grid-items */ - -body.order-by-color .grid-item { - order: var(--order-color); -} -body.order-by-relevance .grid-item { - order: var(--order-relevance); -} - /* Footer */ .footer { @@ -768,3 +766,11 @@ body.order-by-relevance .grid-item { height: 0; width: 0; } + +#loader { + display: flex; + + img { + margin: 30px auto; + } +} diff --git a/tests/dom-utils.test.js b/tests/dom-utils.test.js index 19e2beaf..2ba0ef1d 100644 --- a/tests/dom-utils.test.js +++ b/tests/dom-utils.test.js @@ -1,9 +1,13 @@ +/** + * @jest-environment jsdom + */ + const { hideElement, showElement, - toggleClass, addClass, removeClass, + getColumnsCount, } = require('../public/scripts/dom-utils.js'); const $el = { @@ -11,8 +15,6 @@ const $el = { add: jest.fn().mockName('$el.classList.add'), remove: jest.fn().mockName('$el.classList.remove'), toggle: jest.fn().mockName('$el.classList.toggle'), - add: jest.fn().mockName('$el.classList.add'), - remove: jest.fn().mockName('$el.classList.remove'), }, removeAttribute: jest.fn().mockName('$el.removeAttribute'), setAttribute: jest.fn().mockName('$el.setAttribute'), @@ -65,3 +67,80 @@ describe('::addClass', () => { expect($el.classList.add).toHaveBeenCalledWith('last__button'); }); }); + +describe('getColumnsCount', () => { + const defaultFontSize = 16; + const defaultWidthRem = 13.5; + const defaultGapRem = 0.75; + let getComputedStyleSpy; + + beforeEach(() => { + getComputedStyleSpy = jest.spyOn(window, 'getComputedStyle'); + mockContainerStyles(defaultWidthRem, defaultGapRem); + }); + + afterEach(() => { + getComputedStyleSpy.mockRestore(); + }); + + function mockContainerStyles(widthRem, gapRem) { + getComputedStyleSpy.mockImplementation((element) => { + if (element === document.documentElement) { + return { fontSize: `${defaultFontSize}px` }; + } + + return { + getPropertyValue: (name) => { + if (name === '--grid-width') return String(widthRem); + if (name === '--grid-gap') return String(gapRem); + return ''; + }, + }; + }); + } + + it('calculates with defaults when CSS variables are missing', () => { + mockContainerStyles('', ''); + const container = { clientWidth: 1000 }; + const cols = getColumnsCount(container); + + const defaultMinWidth = 13.5 * defaultFontSize; + const defaultGap = 0.75 * defaultFontSize; + const defaultCols = Math.floor( + (container.clientWidth + defaultGap) / (defaultMinWidth + defaultGap), + ); + + expect(cols).toBe(defaultCols); + }); + + it('uses provided CSS variables', () => { + const container = { clientWidth: 800 }; + const widthRem = 10; + const gapRem = 1; + const minWidthPx = widthRem * defaultFontSize; + const gapPx = gapRem * defaultFontSize; + const expectedCols = Math.floor( + (container.clientWidth + gapPx) / (minWidthPx + gapPx), + ); + + mockContainerStyles(widthRem, gapRem); + const cols = getColumnsCount(container); + + expect(cols).toBe(expectedCols); + }); + + it('returns at least 1 for small widths', () => { + const container = { clientWidth: 10 }; + expect(getColumnsCount(container)).toBe(1); + }); + + it('returns at least 1 for zero width', () => { + const container = { clientWidth: 0 }; + expect(getColumnsCount(container)).toBe(1); + }); + + it('returns at least 1 for negative width', () => { + const container = { clientWidth: -10 }; + expect(getColumnsCount(container)).toBe(1); + }); +}); diff --git a/tests/e2e.test.js b/tests/e2e.test.js index ec338d5b..e23ef463 100644 --- a/tests/e2e.test.js +++ b/tests/e2e.test.js @@ -76,10 +76,10 @@ describe.each([ expect(await isInViewport($orderByColor)).toBeTruthy(); }); - it('has the first grid item in view on load', async () => { - const $firstGridItem = await page.$('.grid-item'); - expect(await isInViewport($firstGridItem)).toBeTruthy(); - }); + // it('has the first grid item in view on load', async () => { + // const $firstGridItem = await page.$('.grid-item'); + // expect(await isInViewport($firstGridItem)).toBeTruthy(); + // }); it('hides the #copy-input element', async () => { const $copyInput = await page.$('#copy-input'); diff --git a/tests/icons.test.js b/tests/icons.test.js new file mode 100644 index 00000000..93385601 --- /dev/null +++ b/tests/icons.test.js @@ -0,0 +1,146 @@ +/** + * @jest-environment jsdom + */ + +import { createListElement, imageCache } from '../public/scripts/icons.js'; + +describe('Icons', () => { + const originalFetch = global.fetch; + const originalCreateObjectURL = global.URL.createObjectURL; + + beforeAll(() => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + blob: () => Promise.resolve(new Blob([''])), + }), + ); + global.URL.createObjectURL = jest.fn(() => 'mock-object-url'); + }); + + afterAll(() => { + global.fetch = originalFetch; + global.URL.createObjectURL = originalCreateObjectURL; + jest.clearAllMocks(); + }); + + it('should create an element with the correct structure and content', () => { + const icon = { + badgeEncodedTitle: 'Test-Title', + shortHex: '123456', + slug: 'test-icon', + superLight: false, + guidelines: 'https://example.com/guidelines', + title: 'Test Icon', + license: { type: 'MIT', url: 'https://example.com/license' }, + indexByColor: '1', + indexByAlpha: 1, + normalizedName: 'test-icon', + }; + + const element = createListElement(icon); + + expect(element).toBeInstanceOf(HTMLElement); + expect(element.getAttribute('data-brand')).toBe('test-icon'); + expect(element.querySelector('.grid-item__title').textContent).toBe( + icon.title, + ); + expect(element.querySelector('.icon-preview').src).toBe( + 'http://localhost/test-file-stub', + ); + expect(element.querySelector('.grid-item__link.link-button').href).toBe( + icon.guidelines, + ); + expect(element.querySelector('.grid-item__link.icon-legal').href).toBe( + icon.license.url, + ); + }); + + it('should use cached image if it exists', () => { + const icon = { + badgeEncodedTitle: 'Cached-Icon', + shortHex: '654321', + slug: 'cached-icon', + superLight: true, + guidelines: '', + title: 'Cached Icon', + license: null, + indexByColor: '2', + normalizedName: 'cached-icon', + }; + const cachedUrl = 'https://cached-image-url.test/'; + + jest.spyOn(imageCache, 'get').mockReturnValueOnce(cachedUrl); + + const element = createListElement(icon); + + expect(imageCache.get).toHaveBeenCalledWith( + 'https://img.shields.io/badge/Cached-Icon-654321?logo=cached-icon&logoColor=000&style=for-the-badge', + ); + expect(element.querySelector('.icon-preview').src).toBe(cachedUrl); + }); + + it('should fetch and set a new image if not cached', async () => { + const icon = { + badgeEncodedTitle: 'New-Icon', + shortHex: 'abcdef', + slug: 'new-icon', + superLight: true, + guidelines: '', + title: 'New Icon', + license: null, + indexByColor: '3', + normalizedName: 'new-icon', + }; + + let resolveFetchPromise; + const fetchPromise = new Promise((resolve) => { + resolveFetchPromise = resolve; + }); + + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + blob: () => fetchPromise, + }), + ); + + jest.spyOn(imageCache, 'get').mockReturnValueOnce(null); + jest.spyOn(imageCache, 'set'); + + const element = createListElement(icon); + resolveFetchPromise(new Blob()); + + await fetchPromise; + await new Promise((r) => setTimeout(r, 0)); + + const expectedUrl = + 'https://img.shields.io/badge/New-Icon-abcdef?logo=new-icon&logoColor=000&style=for-the-badge'; + + expect(imageCache.get).toHaveBeenCalledWith(expectedUrl); + expect(global.fetch).toHaveBeenCalledWith(expectedUrl); + expect(imageCache.set).toHaveBeenCalledWith(expectedUrl, 'mock-object-url'); + expect(element.querySelector('.icon-preview').getAttribute('src')).toBe( + 'mock-object-url', + ); + }); + + it('should handle missing guidelines and license gracefully', () => { + const icon = { + badgeEncodedTitle: 'Minimal-Icon', + shortHex: '000000', + slug: 'minimal-icon', + superLight: false, + guidelines: '', + title: 'Minimal Icon', + license: null, + indexByColor: '4', + normalizedName: 'minimal-icon', + }; + + const element = createListElement(icon); + + expect(element.querySelector('.grid-item__link.link-button')).toBeNull(); + expect(element.querySelector('.grid-item__link.icon-legal')).toBeNull(); + }); +}); diff --git a/tests/mocks/dom-utils.mock.js b/tests/mocks/dom-utils.mock.js index 9f8d6dab..d241c658 100644 --- a/tests/mocks/dom-utils.mock.js +++ b/tests/mocks/dom-utils.mock.js @@ -4,6 +4,8 @@ export const domUtils = { toggleClass: jest.fn().mockName('domUtils.toggleClass'), addClass: jest.fn().mockName('domUtils.addClass'), removeClass: jest.fn().mockName('domUtils.removeClass'), + getColumnsCount: jest.fn().mockName('domUtils.getColumnsCount'), + // Utility to quickly clear the entire dom-utils mock. __resetAllMocks: function () { this.hideElement.mockReset(); @@ -11,5 +13,6 @@ export const domUtils = { this.toggleClass.mockReset(); this.addClass.mockReset(); this.removeClass.mockReset(); + this.getColumnsCount.mockReset(); }, }; diff --git a/tests/mocks/dom.mock.js b/tests/mocks/dom.mock.js index b7fc7e26..cb72b99e 100644 --- a/tests/mocks/dom.mock.js +++ b/tests/mocks/dom.mock.js @@ -2,12 +2,14 @@ const PATHNAME = 'https://www.simpleicons.org'; export const document = { getElementById: jest.fn().mockName('document.getElementById'), + createElement: jest.fn().mockName('document.createElement'), location: { pathname: PATHNAME, search: '', }, querySelector: jest.fn().mockName('document.querySelector'), querySelectorAll: jest.fn().mockName('document.querySelectorAll'), + addEventListener: jest.fn().mockName('document.addEventListener'), // Common elements $body: newElementMock('body'), @@ -16,6 +18,8 @@ export const document = { __resetAllMocks: function () { this.getElementById.mockReset(); this.getElementById.mockImplementation(newElementMock); + this.createElement.mockReset(); + this.createElement.mockImplementation(newElementMock); this.location.pathname = PATHNAME; this.location.search = ''; this.querySelector.mockReset(); @@ -28,6 +32,8 @@ export const document = { }); this.querySelectorAll.mockReset(); this.querySelectorAll.mockReturnValue([]); + this.addEventListener.mockReset(); + this.addEventListener.mockImplementation(); this.$body = newElementMock('body'); }, }; @@ -66,4 +72,10 @@ export function newEventMock() { export const window = { atob: (base64Str) => Buffer.from(base64Str, 'base64').toString('utf8'), + addEventListener: jest.fn().mockName('window.addEventListener'), + + __resetAllMocks: function () { + this.addEventListener.mockReset(); + this.addEventListener.mockImplementation(); + }, }; diff --git a/tests/mocks/image-import.mock.js b/tests/mocks/image-import.mock.js new file mode 100644 index 00000000..86059f36 --- /dev/null +++ b/tests/mocks/image-import.mock.js @@ -0,0 +1 @@ +module.exports = 'test-file-stub'; diff --git a/tests/ordering.test.js b/tests/ordering.test.js index 26d0ab45..99140a60 100644 --- a/tests/ordering.test.js +++ b/tests/ordering.test.js @@ -7,6 +7,11 @@ const { localStorage } = require('./mocks/local-storage.mock.js'); const initOrdering = require('../public/scripts/ordering.js').default; const { STORAGE_KEY_ORDERING } = require('../public/scripts/storage.js'); +const { + ORDER_ALPHABETICALLY, + ORDER_BY_COLOR, + ORDER_BY_RELEVANCE, +} = require('../public/scripts/ordering'); describe('Ordering', () => { beforeEach(() => { @@ -150,4 +155,118 @@ describe('Ordering', () => { storedValue, ); }); + + describe('Badge Sorting Logic', () => { + let orderControls; + let badges; + + beforeEach(() => { + orderControls = initOrdering(document, localStorage); + badges = [ + { + title: 'Bravo', + indexByAlpha: 2, + indexByColor: 2, + relevanceScore: 20, + }, + { + title: 'Alpha', + indexByAlpha: 1, + indexByColor: 3, + relevanceScore: 10, + }, + { + title: 'Zulu', + indexByAlpha: 10, + indexByColor: 1, + relevanceScore: 30, + }, + ]; + }); + + it('sorts by alphabetical (default)', () => { + const byAlpha = orderControls.sortBadges(badges); + expect(byAlpha.map((i) => i.title)).toEqual(['Alpha', 'Bravo', 'Zulu']); + }); + + it('sorts by color', () => { + const byColor = orderControls.sortBadges(badges, ORDER_BY_COLOR); + expect(byColor.map((i) => i.indexByColor)).toEqual([1, 2, 3]); + }); + + it('sorts by relevance', () => { + const byRelevance = orderControls.sortBadges(badges, ORDER_BY_RELEVANCE); + expect(byRelevance.map((i) => i.relevanceScore)).toEqual([10, 20, 30]); + }); + + it('does not mutate the original array', () => { + orderControls.sortBadges(badges); + orderControls.sortBadges(badges, ORDER_BY_COLOR); + orderControls.sortBadges(badges, ORDER_BY_RELEVANCE); + expect(badges.map((i) => i.title)).toEqual(['Bravo', 'Alpha', 'Zulu']); + }); + }); + + it('calls callback (onOrderChange) on user selection', () => { + const eventListeners = new Map(); + const $alpha = newElementMock('#order-alpha'); + const $color = newElementMock('#order-color'); + const $relevance = newElementMock('#order-relevance'); + + $alpha.addEventListener.mockImplementation((n, fn) => + eventListeners.set(`a:${n}`, fn), + ); + $color.addEventListener.mockImplementation((n, fn) => + eventListeners.set(`c:${n}`, fn), + ); + $relevance.addEventListener.mockImplementation((n, fn) => + eventListeners.set(`r:${n}`, fn), + ); + + document.getElementById.mockImplementation((id) => { + if (id === 'order-alpha') return $alpha; + if (id === 'order-color') return $color; + if (id === 'order-relevance') return $relevance; + return newElementMock(id); + }); + + const getBadges = jest.fn(); + const onOrderChange = jest.fn(); + initOrdering(document, localStorage, getBadges, onOrderChange); + + // click color => callback with 'color' + eventListeners.get('c:click')({ preventDefault: jest.fn() }); + expect(onOrderChange).toHaveBeenCalled(); + + // click relevance => call callback and without recording in storage + localStorage.setItem.mockClear(); + eventListeners.get('r:click')({ preventDefault: jest.fn() }); + expect(localStorage.setItem).not.toHaveBeenCalled(); + expect(onOrderChange).toHaveBeenCalled(); + }); + + it('calls callback (onOrderChange) during init if storage has a value', () => { + localStorage.__setStoredValueFor(STORAGE_KEY_ORDERING, ORDER_BY_COLOR); + const getBadges = jest.fn(); + const onOrderChange = jest.fn(); + + initOrdering(document, localStorage, getBadges, onOrderChange); + expect(onOrderChange).toHaveBeenCalled(); + }); + + it('does nothing when selecting the same ordering again', () => { + const getBadges = jest.fn(); + const onOrderChange = jest.fn(); + const orderControls = initOrdering( + document, + localStorage, + getBadges, + onOrderChange, + ); + + // by default alphabetically + onOrderChange.mockClear(); + orderControls.selectOrdering(ORDER_ALPHABETICALLY); + expect(onOrderChange).not.toHaveBeenCalled(); + }); }); diff --git a/tests/search.test.js b/tests/search.test.js index a21ce29d..289888d4 100644 --- a/tests/search.test.js +++ b/tests/search.test.js @@ -182,6 +182,98 @@ describe('Search', () => { ); done(); }); + + it('calls callback (onSearch) with correct results and normalized query', () => { + const onSearch = jest.fn(); + const searchValue = 'GIT'; + const badges = [ + { normalizedName: 'google', title: 'Google' }, + { normalizedName: 'github', title: 'GitHub' }, + ]; + + initSearch(history, document, ordering, domUtils, badges, onSearch); + + const inputListener = inputEventListeners.get('input'); + const event = newEventMock(); + + $searchInput.value = searchValue; + inputListener(event); + jest.runAllTimers(); + + expect(onSearch).toHaveBeenCalled(); + const [results, normalizedQuery] = onSearch.mock.calls.pop(); + expect(normalizedQuery).toBe('git'); + expect(results.every((r) => typeof r.relevanceScore === 'number')).toBe( + true, + ); + }); + }); + + describe('Badge Searching Logic', () => { + it('returns an empty array if no badges are passed', () => { + const searchValue = 'FOO'; + const onSearch = jest.fn(); + const badges = []; + + initSearch(history, document, ordering, domUtils, badges, onSearch); + + const inputListener = inputEventListeners.get('input'); + const event = newEventMock(); + + $searchInput.value = searchValue; + inputListener(event); + jest.runAllTimers(); + + const [results, normalizedQuery] = onSearch.mock.calls.pop(); + expect(normalizedQuery).toBe('foo'); + expect(results).toEqual([]); + }); + + it('returns the entire array if no search query is passed', () => { + const searchValue = ''; + const onSearch = jest.fn(); + const badges = [ + { normalizedName: 'google' }, + { normalizedName: 'github' }, + ]; + + initSearch(history, document, ordering, domUtils, badges, onSearch); + + const inputListener = inputEventListeners.get('input'); + const event = newEventMock(); + + $searchInput.value = searchValue; + inputListener(event); + jest.runAllTimers(); + + const [results, normalizedQuery] = onSearch.mock.calls.pop(); + expect(normalizedQuery).toBe(''); + expect(results.map((r) => r.normalizedName)).toEqual([ + 'google', + 'github', + ]); + }); + + it('filters badges by score', () => { + const searchValue = 'ac'; + const onSearch = jest.fn(); + const badges = [ + { normalizedName: 'abc', title: 'A' }, // score >= 0 for query "ac" + { normalizedName: 'zzz', title: 'Z' }, // score -1 for query "ac" + ]; + + initSearch(history, document, ordering, domUtils, badges, onSearch); + + const inputListener = inputEventListeners.get('input'); + const event = newEventMock(); + + $searchInput.value = searchValue; + inputListener(event); + jest.runAllTimers(); + + const [results] = onSearch.mock.calls.pop(); + expect(results.map((r) => r.title)).toEqual(['A']); + }); }); describe('URL query', () => { diff --git a/tests/virtual-window.test.js b/tests/virtual-window.test.js new file mode 100644 index 00000000..2b82422e --- /dev/null +++ b/tests/virtual-window.test.js @@ -0,0 +1,74 @@ +import { window, document } from './mocks/dom.mock.js'; +const utils = require('../public/scripts/utils.js'); +const domUtils = require('../public/scripts/dom-utils.js'); + +jest.mock('virtual-scroller/dom', () => { + return jest.fn().mockImplementation(() => ({ setItems: jest.fn() })); +}); +jest.mock('../public/scripts/icons.js', () => ({ + createListElement: jest.fn(() => ({ + /* node */ + })), +})); + +const VirtualScroller = require('virtual-scroller/dom'); +const initVirtualWindow = + require('../public/scripts/virtual-window.js').default; + +describe('virtual-window', () => { + let getBadges; + const initialBadges = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + + beforeEach(() => { + window.__resetAllMocks(); + document.__resetAllMocks(); + + getBadges = jest.fn(() => initialBadges).mockName('getBadges'); + jest.spyOn(domUtils, 'getColumnsCount').mockReturnValue(2); + jest.spyOn(utils, 'groupIntoRows'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('throws error when container not found', () => { + document.getElementById.mockReturnValue(null); + expect(() => initVirtualWindow(window, document, getBadges)).toThrowError(); + }); + + it('initializes VirtualScroller', () => { + const container = { id: 'virtual-list', clientWidth: 800 }; + document.getElementById.mockReturnValue(container); + + const { virtualWindowContainer } = initVirtualWindow( + window, + document, + getBadges, + ); + expect(VirtualScroller).toHaveBeenCalled(); + expect(virtualWindowContainer).toBe(container); + }); + + it('wires resize regrouping', () => { + const container = { id: 'virtual-list', clientWidth: 800 }; + document.getElementById.mockReturnValue(container); + + const { scroller } = initVirtualWindow(window, document, getBadges); + jest.spyOn(scroller, 'setItems'); + + const listener = window.addEventListener.mock.calls.find( + (c) => c[0] === 'resize', + )[1]; + + // without changing the column count + domUtils.getColumnsCount.mockReturnValue(2); + listener(); + expect(scroller.setItems).not.toHaveBeenCalled(); + + // with changing the column count + domUtils.getColumnsCount.mockReturnValue(5); + listener(); + expect(scroller.setItems).toHaveBeenCalled(); + }); +}); diff --git a/webpack.config.js b/webpack.config.js index 2b72ec64..3819131c 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -5,6 +5,7 @@ const HtmlWebpackPlugin = require('html-webpack-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const path = require('path'); const simpleIcons = require('simple-icons'); +const webpack = require('webpack'); const { normalizeSearchTerm } = require('./public/scripts/utils.js'); const sortByColors = require('./scripts/color-sorting.js'); @@ -32,6 +33,28 @@ if (process.env.TEST_ENV) { displayIcons = icons.slice(0, 255); } +const iconsData = displayIcons.map((icon, iconIndex) => { + const luminance = getRelativeLuminance(`#${icon.hex}`); + return { + guidelines: icon.guidelines, + hex: icon.hex, + indexByAlpha: iconIndex, + indexByColor: sortedHexes.indexOf(icon.hex), + license: icon.license, + light: luminance < 0.4, + superLight: luminance > 0.55, + superDark: luminance < 0.02, + normalizedName: normalizeSearchTerm(icon.title), + path: icon.path, + shortHex: simplifyHexIfPossible(icon.hex), + slug: icon.slug, + title: icon.title, + badgeEncodedTitle: encodeURIComponent( + icon.title.replaceAll('-', '--').replaceAll('_', '__'), + ), + }; +}); + module.exports = (env, argv) => { return { entry: { @@ -77,28 +100,7 @@ module.exports = (env, argv) => { inject: true, template: path.resolve(ROOT_DIR, 'index.pug'), templateParameters: { - icons: displayIcons.map((icon, iconIndex) => { - const luminance = getRelativeLuminance(`#${icon.hex}`); - return { - base64Svg: Buffer.from(icon.svg).toString('base64'), - guidelines: icon.guidelines, - hex: icon.hex, - indexByAlpha: iconIndex, - indexByColor: sortedHexes.indexOf(icon.hex), - license: icon.license, - light: luminance < 0.4, - superLight: luminance > 0.55, - superDark: luminance < 0.02, - normalizedName: normalizeSearchTerm(icon.title), - path: icon.path, - shortHex: simplifyHexIfPossible(icon.hex), - slug: icon.slug, - title: icon.title, - badgeEncodedTitle: encodeURIComponent( - icon.title.replaceAll('-', '--').replaceAll('_', '__'), - ), - }; - }), + icons: iconsData, iconCount: icons.length, twitterIcon: icons.find((icon) => icon.title === 'X'), pageTitle: 'Simple Badges', @@ -107,6 +109,9 @@ module.exports = (env, argv) => { }, }), new MiniCssExtractPlugin(), + new webpack.DefinePlugin({ + ICONS_DATA: JSON.stringify(iconsData), + }), ], optimization: { minimizer: