Changelog 473a319
search modal
5 files of code, design or docs +118 −2 lines
Code, design and documents 5 files
source-archive.json +1 −1
Not shown: a picture, a generated file, or past the length shown per change.
src/components/HomePage.astro +1 −1
@@ -153,5 +153,5 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments", {list.map((c) => ( <li> - <a href={`${base}country/${c.slug}/`} data-country={c.id} style={`--c:${tierColor(c.score)}`}> + <a href={`${base}country/${c.slug}/`} style={`--c:${tierColor(c.score)}`}> <span class="ext-rank">{String(c.rank).padStart(2, "0")}</span> <span class="ext-name"><span class="flag">{c.flag}</span>{c.name}</span>
src/layouts/Base.astro +69 −0
@@ -64,4 +64,7 @@ const schemas = jsonld ? [jsonld].flat() : []; <a href={`${base}compare/`}>Compare</a> </nav> + <button class="icon-btn search-btn" id="searchOpen" aria-label="Search" title="Search (⌘K)" aria-keyshortcuts="Meta+K Control+K"> + <svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true"><circle cx="11" cy="11" r="6.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M16 16l5 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg> + </button> <a class="icon-btn" href={repo} target="_blank" rel="noopener noreferrer" aria-label="Source code on GitHub" title="Source code on GitHub"> <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path fill="currentColor" d="M12 2a10 10 0 0 0-3.16 19.49c.5.09.68-.22.68-.48v-1.7c-2.78.6-3.37-1.34-3.37-1.34-.46-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.9 1.52 2.34 1.08 2.91.83.09-.65.35-1.09.63-1.34-2.22-.25-4.56-1.11-4.56-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02a9.560 9.560 0 0 1 5 0c1.910-1.290 2.750-1.020 2.750-1.020.550 1.380.200 2.400.100 2.650.640.700 1.030 1.590 1.030 2.680 0 3.840-2.340 4.680-4.570 4.930.360.310.680.920.680 1.850v2.740c0 .270.180.580.690.480A10 10 0 0 0 12 2z"/></svg> @@ -74,4 +77,15 @@ const schemas = jsonld ? [jsonld].flat() : []; <slot /> + {/* Search: ⌘K or Ctrl+K anywhere, or the magnifier in the top bar. The index is /search.json. */} + <dialog class="search" id="siteSearch" aria-label="Search" data-index={`${base}search.json`} data-base={base}> + <form method="dialog" class="search-box"> + <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><circle cx="11" cy="11" r="6.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M16 16l5 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg> + <input id="searchInput" type="search" placeholder="Search countries, laws and events" autocomplete="off" spellcheck="false" aria-controls="searchResults" /> + <kbd>esc</kbd> + </form> + <ol class="search-results" id="searchResults" role="listbox"></ol> + <p class="search-hint" id="searchHint">Type a country, a year or a few words. <span><kbd>↑</kbd><kbd>↓</kbd> to move, <kbd>↵</kbd> to open.</span></p> + </dialog> + <footer class="footer"> <p>Cannabis Freedom Index · Informational only, not legal advice · Map data © Natural Earth</p> @@ -81,4 +95,59 @@ const schemas = jsonld ? [jsonld].flat() : []; <script is:inline> + // Search dialog. The index is loaded on first use. Every word typed must appear in a row's + // name or its second line; countries and pages come first, then entries with an article, + // then the rest of the history, newest first. + (function () { + var dialog = document.getElementById("siteSearch"), input = document.getElementById("searchInput"); + var list = document.getElementById("searchResults"), hint = document.getElementById("searchHint"); + var rows = null, loading = null, shown = [], active = 0; + var KIND = { c: "Country", p: "Page", a: "Article", h: "History" }, ORDER = { c: 0, p: 1, a: 2, h: 3 }; + function fold(s) { return s.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""); } + function esc(s) { return String(s).replace(/[&<>"]/g, function (ch) { return { "&": "&", "<": "<", ">": ">", '"': """ }[ch]; }); } + function load() { + if (!loading) loading = fetch(dialog.dataset.index).then(function (r) { return r.json(); }).then(function (data) { + rows = data.map(function (r, i) { r.i = i; r.k = fold(r.n); r.h = r.k + " " + fold(r.s); return r; }); + render(); + }).catch(function () { hint.textContent = "The search index could not be loaded."; }); + return loading; + } + function render() { + var words = fold(input.value).split(/\s+/).filter(Boolean); + if (!rows || !words.length) { shown = []; list.innerHTML = ""; hint.hidden = false; return; } + var found = rows.filter(function (r) { return words.every(function (w) { return r.h.indexOf(w) >= 0; }); }); + var q = words.join(" "); + function rank(r) { return ORDER[r.t] * 10 + (r.k === q ? 0 : r.k.indexOf(q) === 0 ? 1 : r.k.indexOf(q) > 0 ? 2 : 3); } + found.sort(function (a, b) { return rank(a) - rank(b) || a.i - b.i; }); + shown = found.slice(0, 40); active = 0; hint.hidden = shown.length > 0; + if (!shown.length) hint.textContent = "Nothing found for “" + input.value + "”."; + list.innerHTML = shown.map(function (r, i) { + return '<li role="option" id="sr' + i + '"' + (i === 0 ? ' aria-selected="true"' : "") + '><a href="' + esc(dialog.dataset.base + r.u) + '" tabindex="-1">' + + '<span class="sr-flag" aria-hidden="true">' + esc(r.f || "") + '</span><span class="sr-name">' + esc(r.n) + '</span>' + + '<span class="sr-sub">' + esc(r.s) + '</span><span class="sr-kind">' + KIND[r.t] + "</span></a></li>"; + }).join(""); + } + function move(step) { + if (!shown.length) return; + list.children[active].removeAttribute("aria-selected"); + active = (active + step + shown.length) % shown.length; + var li = list.children[active]; li.setAttribute("aria-selected", "true"); li.scrollIntoView({ block: "nearest" }); + input.setAttribute("aria-activedescendant", li.id); + } + function open() { if (dialog.open) return; dialog.showModal(); input.value = ""; render(); input.focus(); load(); } + document.getElementById("searchOpen").addEventListener("click", open); + document.addEventListener("keydown", function (e) { + if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "k") { e.preventDefault(); dialog.open ? dialog.close() : open(); } + }); + input.addEventListener("input", render); + input.addEventListener("keydown", function (e) { + if (e.key === "ArrowDown") { e.preventDefault(); move(1); } + else if (e.key === "ArrowUp") { e.preventDefault(); move(-1); } + else if (e.key === "Enter") { e.preventDefault(); if (shown.length) { dialog.close(); location.href = list.children[active].firstChild.href; } } + }); + // a click on the backdrop closes it; a click on a result is an ordinary link + dialog.addEventListener("click", function (e) { if (e.target === dialog) dialog.close(); }); + list.addEventListener("click", function (e) { if (e.target.closest("a")) dialog.close(); }); + })(); + document.getElementById("themeToggle").addEventListener("click", function () { var root = document.documentElement;
src/pages/search.json.js +28 −0
@@ -0,0 +1,28 @@ +// What the search dialog (⌘K, in Base.astro) looks through: every country and state, every history +// entry and every page (map views, history kinds and periods included), as short rows. Fetched once, the first time the dialog is opened. +// t: kind (c country, a history entry with an article, h history entry, p page) +// n: what is shown and matched s: the smaller second line, matched too u: address under the site root +import { places, countries, subunitsOf, changes, articles, historyPeriods, CHANGE_CATEGORIES } from "../lib/countries.js"; +import { MAP_VIEWS } from "../lib/map-views.js"; +import { tierOf } from "../lib/tiers.js"; +import { file } from "../lib/exports.js"; + +const rows = [ + ...places.map((c) => ({ t: "c", n: c.name, s: `${c.score}/100 · ${tierOf(c.raw).name}`, u: `country/${c.slug}/`, f: c.flag })), + // states, provinces and territories of the federations + ...countries.flatMap((c) => (subunitsOf(c.id)?.units ?? []).map((sub) => ({ t: "c", n: sub.name, s: `${c.name} · ${sub.score}/100 · ${tierOf(sub.raw).name}`, u: `country/${c.slug}/${sub.id}/`, f: c.flag }))), + ...[ + ["Map", "World map of scores", "#map"], ["Rankings", "Every country by score", "#rankings"], ["Methodology", "How the scores are made", "#methodology"], + ["History", "Laws, rulings and treaties", "history/"], ["Can I?", "Plain answers per country", "can-i/"], ["Compare", "Countries side by side", "compare/"], + ["Download the data", "Scores, history and yearly panel", "data/"], ["Changelog", "Every score change", "changelog/"], + ["Articles", "The longer reads on single events", "articles/"], ["Culture", "The people behind the laws", "culture/"], ["Sources", "Every source the index cites", "sources/"], + ].map(([n, s, u]) => ({ t: "p", n, s, u })), + // every map view, history kind and history period has its own page + ...MAP_VIEWS.filter((v) => v.slug).map((v) => ({ t: "p", n: v.title, s: "Map", u: `map/${v.slug}/` })), + ...Object.entries(CHANGE_CATEGORIES).map(([kind, k]) => ({ t: "p", n: `History: ${k.label}`, s: k.help, u: `history/kind/${kind}/`, f: k.emoji })), + ...historyPeriods.slice(1).map((p) => ({ t: "p", n: `History, ${p.label}`, s: "Laws, rulings and treaties of the period", u: `history/period/${p.slug}/` })), + // newest first, as in `changes`; estimates have no real date and no page worth finding + ...changes.filter((ch) => !ch.estimate).map((ch) => ({ t: articles.has(ch.id) ? "a" : "h", n: ch.title, s: `${ch.date.slice(0, 4)} · ${ch.place.name}`, u: `history/${ch.id}/`, f: ch.place.flag })), +]; + +export const GET = () => file(JSON.stringify(rows), "application/json");
src/styles/global.css +19 −0
@@ -1277,2 +1277,21 @@ details.segrow .segrow-top > span, .segrow:not(details) .segrow-top > span { fon .src-meta b { font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-left: 10px; } .sources-page .hist-stats { margin-top: 8px; } + +/* Search dialog (⌘K): a flat ruled sheet near the top of the window */ +.search { width: min(680px, calc(100vw - 32px)); max-height: min(70vh, 640px); margin: 12vh auto 0; padding: 0; border: 1px solid var(--text); border-radius: var(--r); background: var(--bg); color: var(--text); overflow: hidden; } +.search[open] { display: flex; flex-direction: column; } +.search::backdrop { background: color-mix(in srgb, var(--bg) 70%, transparent); backdrop-filter: blur(3px); } +.search-box { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--border); color: var(--muted); } +.search-box input { flex: 1; min-width: 0; border: 0; outline: 0; background: none; color: var(--text); font: 400 18px/1.3 var(--sans, inherit); } +.search-box input::-webkit-search-cancel-button { display: none; } +.search kbd { font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); border: 1px solid var(--border); border-radius: var(--r); padding: 2px 6px; } +.search-results { list-style: none; margin: 0; padding: 0; overflow-y: auto; } +.search-results a { display: grid; grid-template-columns: 26px minmax(0, 1fr) auto; grid-template-areas: "flag name kind" "flag sub kind"; column-gap: 10px; align-items: baseline; padding: 9px 16px; border-bottom: 1px solid var(--border); color: var(--text); } +.search-results li[aria-selected="true"] a, .search-results a:hover { background: var(--surface-2); } +.sr-flag { grid-area: flag; align-self: center; font-size: 17px; } +.sr-name { grid-area: name; font: 400 19px/1.2 var(--display); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sr-sub { grid-area: sub; font-size: 12.5px; color: var(--muted); } +.sr-kind { grid-area: kind; align-self: center; font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); } +.search-hint { margin: 0; padding: 14px 16px; color: var(--muted); font-size: 13px; display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; } +.search-hint kbd { margin-right: 3px; } +.topbar .search-btn { margin-right: -12px; }