diff --git a/_includes/footer.html b/_includes/footer.html index a4a549107..0d39a63da 100644 --- a/_includes/footer.html +++ b/_includes/footer.html @@ -15,20 +15,25 @@ - {% if page.collection == "licenses" or page.class == "license-types" %} + + {% if page.collection == "licenses" or page.class == "license-types" %} {% endif %} diff --git a/_includes/header.html b/_includes/header.html index 7e2b10d2e..1b2eede7c 100644 --- a/_includes/header.html +++ b/_includes/header.html @@ -15,6 +15,8 @@ {% include breadcrumbs.html %} + {% include site-search.html %} + {% if page.title %}

{{ page.title | markdownify | strip_html | strip_newlines }}

{% endif %} diff --git a/_includes/site-search.html b/_includes/site-search.html new file mode 100644 index 000000000..040f74ff8 --- /dev/null +++ b/_includes/site-search.html @@ -0,0 +1,13 @@ + diff --git a/assets/css/application.scss b/assets/css/application.scss index 11df9809e..489216a27 100644 --- a/assets/css/application.scss +++ b/assets/css/application.scss @@ -568,6 +568,67 @@ strong { .with-love { float: right; clear: right; } +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.site-search { + position: relative; + margin: 0 0 1.25rem; + max-width: 36rem; +} + +.site-search input[type="search"] { + width: 100%; + padding: 0.55rem 0.75rem; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 1rem; +} + +.site-search-results { + position: absolute; + z-index: 1000; + left: 0; + right: 0; + margin: 0.25rem 0 0; + padding: 0; + list-style: none; + background: var(--backgroundColor); + border: 1px solid #ccc; + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + max-height: 18rem; + overflow-y: auto; +} + +.site-search-results a { + display: flex; + justify-content: space-between; + gap: 1rem; + padding: 0.5rem 0.75rem; + font-weight: normal; +} + +.site-search-results li.is-active a, +.site-search-results a:hover { + background: rgba(20, 154, 212, 0.12); +} + +.site-search-meta { + font-size: 0.75rem; + color: #888; + font-weight: normal; +} + @media only screen and (-webkit-min-device-pixel-ratio: 2.0), only screen and (min--moz-device-pixel-ratio: 2.0), only screen and (-o-min-device-pixel-ratio: 200/100), diff --git a/assets/js/search.js b/assets/js/search.js new file mode 100644 index 000000000..869448bc5 --- /dev/null +++ b/assets/js/search.js @@ -0,0 +1,119 @@ +class SiteSearch { + constructor(inputEl, resultsEl, catalog) { + this.inputEl = inputEl; + this.resultsEl = resultsEl; + this.catalog = catalog; + this.selectedIndex = -1; + + this.inputEl.addEventListener('input', () => this.render()); + this.inputEl.addEventListener('keydown', (event) => this.onKeyDown(event)); + this.inputEl.addEventListener('focus', () => this.render()); + document.addEventListener('click', (event) => { + if (!event.target.closest('#site-search')) this.hideResults(); + }); + } + + normalize(text) { + return (text || '').toLowerCase(); + } + + score(entry, query) { + const q = this.normalize(query); + if (!q) return -1; + + const title = this.normalize(entry.title); + const spdx = this.normalize(entry.spdx_id); + const description = this.normalize(entry.description); + const tags = (entry.tags || []).join(' '); + + if (spdx === q || title === q) return 100; + if (spdx.startsWith(q) || title.startsWith(q)) return 80; + if (spdx.includes(q) || title.includes(q)) return 60; + if (description.includes(q) || tags.includes(q)) return 40; + return -1; + } + + matches(query) { + return this.catalog + .map((entry) => ({ entry, score: this.score(entry, query) })) + .filter(({ score }) => score >= 0) + .sort((a, b) => b.score - a.score || a.entry.title.localeCompare(b.entry.title)) + .slice(0, 12) + .map(({ entry }) => entry); + } + + render() { + const query = this.inputEl.value.trim(); + const results = this.matches(query); + this.resultsEl.innerHTML = ''; + + if (!query || results.length === 0) { + this.hideResults(); + return; + } + + results.forEach((entry, index) => { + const li = document.createElement('li'); + const a = document.createElement('a'); + a.href = entry.url; + a.textContent = entry.title; + const meta = document.createElement('span'); + meta.className = 'site-search-meta'; + meta.textContent = entry.spdx_id; + a.appendChild(meta); + li.appendChild(a); + if (index === this.selectedIndex) li.classList.add('is-active'); + this.resultsEl.appendChild(li); + }); + + this.resultsEl.hidden = false; + this.inputEl.setAttribute('aria-expanded', 'true'); + } + + hideResults() { + this.resultsEl.hidden = true; + this.inputEl.setAttribute('aria-expanded', 'false'); + this.selectedIndex = -1; + } + + onKeyDown(event) { + const items = Array.from(this.resultsEl.querySelectorAll('li')); + if (!items.length) return; + + if (event.key === 'ArrowDown') { + event.preventDefault(); + this.selectedIndex = Math.min(this.selectedIndex + 1, items.length - 1); + this.render(); + items[this.selectedIndex]?.querySelector('a')?.focus(); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + this.selectedIndex = Math.max(this.selectedIndex - 1, 0); + this.render(); + items[this.selectedIndex]?.querySelector('a')?.focus(); + } else if (event.key === 'Escape') { + this.hideResults(); + this.inputEl.blur(); + } else if (event.key === 'Enter' && this.selectedIndex >= 0) { + event.preventDefault(); + items[this.selectedIndex]?.querySelector('a')?.click(); + } + } +} + +document.addEventListener('DOMContentLoaded', () => { + const inputEl = document.querySelector('#site-search-input'); + const resultsEl = document.querySelector('#site-search-results'); + const catalog = window.licenseCatalog || []; + if (!inputEl || !resultsEl || !catalog.length) return; + + new SiteSearch(inputEl, resultsEl, catalog); + + document.addEventListener('keydown', (event) => { + if (event.key === '/' && document.activeElement !== inputEl && !event.metaKey && !event.ctrlKey) { + const tag = document.activeElement?.tagName?.toLowerCase(); + if (tag === 'input' || tag === 'textarea') return; + event.preventDefault(); + inputEl.focus(); + } + }); +});