Cannabis Freedom Index

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 { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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; }

    ↑↓ to move, ↵ to open.