|
| 1 | +--- |
| 2 | +/** |
| 3 | + * The command palette: ⌘K (or `/`) from any page, results grouped by kind as |
| 4 | + * you type, arrow keys and Enter. Loads the search index on first open and |
| 5 | + * shares the ranking with /search, so a query lands on the same row either |
| 6 | + * way. Without JavaScript the header field is still a form that submits to |
| 7 | + * /search. |
| 8 | + */ |
| 9 | +--- |
| 10 | + |
| 11 | +<dialog id="palette" class="palette" aria-label="Search"> |
| 12 | + <div class="mx-auto mt-[12vh] w-[min(40rem,calc(100%-2rem))] overflow-hidden rounded-xl border border-rule bg-surface shadow-2xl"> |
| 13 | + <label class="flex h-12 items-center gap-3 border-b border-rule px-4"> |
| 14 | + <svg class="h-4 w-4 shrink-0 text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" aria-hidden="true"> |
| 15 | + <circle cx="11" cy="11" r="7" /> |
| 16 | + <path d="M20 20l-3.5-3.5" /> |
| 17 | + </svg> |
| 18 | + <input |
| 19 | + id="palette-input" |
| 20 | + type="text" |
| 21 | + placeholder="Search attributes, metrics, requirements, OTLP…" |
| 22 | + autocomplete="off" |
| 23 | + autocorrect="off" |
| 24 | + spellcheck="false" |
| 25 | + aria-controls="palette-list" |
| 26 | + aria-autocomplete="list" |
| 27 | + role="combobox" |
| 28 | + aria-expanded="true" |
| 29 | + class="h-full w-full bg-transparent font-mono text-[14px] text-ink outline-none placeholder:font-sans placeholder:text-faint" |
| 30 | + /> |
| 31 | + <kbd class="hidden shrink-0 rounded border border-rule px-1.5 py-0.5 font-mono text-[10px] text-muted sm:inline">esc</kbd> |
| 32 | + </label> |
| 33 | + |
| 34 | + <div id="palette-list" role="listbox" class="max-h-[min(60vh,32rem)] overflow-y-auto overscroll-contain py-1.5"></div> |
| 35 | + |
| 36 | + <div class="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-rule px-4 py-2 text-[11px] text-faint"> |
| 37 | + <span><kbd class="font-mono">↑↓</kbd> move</span> |
| 38 | + <span><kbd class="font-mono">↵</kbd> open</span> |
| 39 | + <span class="hidden sm:inline"> |
| 40 | + Prefix with <code class="code-chip">attr:</code> <code class="code-chip">metric:</code> |
| 41 | + <code class="code-chip">spec:</code> <code class="code-chip">otlp:</code> to narrow |
| 42 | + </span> |
| 43 | + <a href="/agents" class="ml-auto no-underline hover:underline">Use with your agent →</a> |
| 44 | + </div> |
| 45 | + </div> |
| 46 | +</dialog> |
| 47 | + |
| 48 | +<script> |
| 49 | + import { |
| 50 | + GROUP_LABEL, |
| 51 | + STATIC_PAGES, |
| 52 | + TYPE_RANK, |
| 53 | + highlight, |
| 54 | + index, |
| 55 | + loadIndex, |
| 56 | + recentQueries, |
| 57 | + rememberQuery, |
| 58 | + search, |
| 59 | + type Indexed, |
| 60 | + } from "../lib/search-client.ts" |
| 61 | + |
| 62 | + const dialog = document.getElementById("palette") as HTMLDialogElement |
| 63 | + const input = document.getElementById("palette-input") as HTMLInputElement |
| 64 | + const list = document.getElementById("palette-list") as HTMLElement |
| 65 | + const trigger = document.querySelector<HTMLInputElement>("[data-palette-trigger]") |
| 66 | + const kbd = document.querySelector<HTMLElement>("[data-palette-kbd]") |
| 67 | + |
| 68 | + if (kbd && !/Mac|iPhone|iPad/.test(navigator.platform)) kbd.textContent = "Ctrl K" |
| 69 | + |
| 70 | + let entries: Indexed[] | null = null |
| 71 | + let selected = 0 |
| 72 | + let rows: HTMLElement[] = [] |
| 73 | + const PAGES = STATIC_PAGES.map(index) |
| 74 | + const PER_GROUP = 6 |
| 75 | + const TOTAL = 28 |
| 76 | + |
| 77 | + const open = (seed = "") => { |
| 78 | + if (dialog.open) return |
| 79 | + dialog.showModal() |
| 80 | + document.body.style.overflow = "hidden" |
| 81 | + input.value = seed |
| 82 | + render() |
| 83 | + input.focus() |
| 84 | + input.select() |
| 85 | + if (!entries) { |
| 86 | + loadIndex().then((loaded) => { |
| 87 | + entries = loaded |
| 88 | + if (dialog.open) render() |
| 89 | + }) |
| 90 | + } |
| 91 | + } |
| 92 | + |
| 93 | + const close = () => { |
| 94 | + dialog.close() |
| 95 | + document.body.style.overflow = "" |
| 96 | + } |
| 97 | + |
| 98 | + const el = (tag: string, className: string, text?: string) => { |
| 99 | + const node = document.createElement(tag) |
| 100 | + node.className = className |
| 101 | + if (text !== undefined) node.textContent = text |
| 102 | + return node |
| 103 | + } |
| 104 | + |
| 105 | + const heading = (label: string) => el("div", "eyebrow px-4 pt-3 pb-1", label) |
| 106 | + |
| 107 | + const row = (entry: Indexed, terms: readonly string[]) => { |
| 108 | + const a = document.createElement("a") |
| 109 | + a.href = entry.url |
| 110 | + a.setAttribute("role", "option") |
| 111 | + a.className = |
| 112 | + "palette-row mx-1.5 flex items-baseline gap-x-3 rounded-lg px-2.5 py-1.5 no-underline transition-colors hover:bg-elevated aria-selected:bg-elevated" |
| 113 | + const id = el("span", entry.type === "page" || entry.type === "domain" ? "shrink-0 text-[13.5px] font-medium text-ink" : "shrink-0 font-mono text-[13px] font-medium text-ink") |
| 114 | + id.innerHTML = highlight(entry.id, terms) |
| 115 | + a.append(id) |
| 116 | + if (entry.renamedTo) a.append(el("span", "shrink-0 font-mono text-[12px] text-muted", `→ ${entry.renamedTo}`)) |
| 117 | + const text = el("span", "min-w-0 truncate text-[12.5px] text-muted") |
| 118 | + text.innerHTML = highlight(entry.text, terms) |
| 119 | + a.append(text) |
| 120 | + if (entry.meta && entry.type !== "page") { |
| 121 | + const meta = el("span", "ml-auto hidden shrink-0 font-mono text-[10.5px] text-faint sm:inline", entry.meta.split(" · ")[0] ?? "") |
| 122 | + a.append(meta) |
| 123 | + } |
| 124 | + a.addEventListener("click", () => rememberQuery(input.value)) |
| 125 | + return a |
| 126 | + } |
| 127 | + |
| 128 | + const linkRow = (label: string, href: string, hint: string) => { |
| 129 | + const a = document.createElement("a") |
| 130 | + a.href = href |
| 131 | + a.setAttribute("role", "option") |
| 132 | + a.className = |
| 133 | + "palette-row mx-1.5 flex items-baseline gap-x-3 rounded-lg px-2.5 py-1.5 text-[13px] no-underline transition-colors hover:bg-elevated aria-selected:bg-elevated" |
| 134 | + a.append(el("span", "text-ink", label), el("span", "text-[12px] text-muted", hint)) |
| 135 | + return a |
| 136 | + } |
| 137 | + |
| 138 | + const select = (i: number) => { |
| 139 | + if (rows.length === 0) return |
| 140 | + selected = (i + rows.length) % rows.length |
| 141 | + rows.forEach((r, n) => r.setAttribute("aria-selected", String(n === selected))) |
| 142 | + rows[selected]?.scrollIntoView({ block: "nearest" }) |
| 143 | + } |
| 144 | + |
| 145 | + function render() { |
| 146 | + const raw = input.value.trim() |
| 147 | + list.replaceChildren() |
| 148 | + |
| 149 | + if (raw === "") { |
| 150 | + const recent = recentQueries() |
| 151 | + if (recent.length > 0) { |
| 152 | + list.append(heading("Recent")) |
| 153 | + for (const q of recent) { |
| 154 | + const b = document.createElement("button") |
| 155 | + b.type = "button" |
| 156 | + b.setAttribute("role", "option") |
| 157 | + b.className = |
| 158 | + "palette-row mx-1.5 flex w-[calc(100%-0.75rem)] items-baseline gap-x-3 rounded-lg px-2.5 py-1.5 text-left font-mono text-[13px] text-ink transition-colors hover:bg-elevated aria-selected:bg-elevated" |
| 159 | + b.textContent = q |
| 160 | + b.addEventListener("click", () => { |
| 161 | + input.value = q |
| 162 | + render() |
| 163 | + input.focus() |
| 164 | + }) |
| 165 | + list.append(b) |
| 166 | + } |
| 167 | + } |
| 168 | + list.append(heading("Pages")) |
| 169 | + for (const page of PAGES) list.append(row(page, [])) |
| 170 | + list.append(heading("Try")) |
| 171 | + for (const [q, hint] of [ |
| 172 | + ["http status", "an attribute by what it holds"], |
| 173 | + ["db.statement", "a deprecated name, and what replaced it"], |
| 174 | + ["spec: retry", "what the specification requires"], |
| 175 | + ["metric: memory usage", "instruments and units"], |
| 176 | + ] as const) { |
| 177 | + const b = document.createElement("button") |
| 178 | + b.type = "button" |
| 179 | + b.setAttribute("role", "option") |
| 180 | + b.className = |
| 181 | + "palette-row mx-1.5 flex w-[calc(100%-0.75rem)] items-baseline gap-x-3 rounded-lg px-2.5 py-1.5 text-left transition-colors hover:bg-elevated aria-selected:bg-elevated" |
| 182 | + b.append(el("span", "font-mono text-[13px] text-ink", q), el("span", "text-[12px] text-muted", hint)) |
| 183 | + b.addEventListener("click", () => { |
| 184 | + input.value = q |
| 185 | + render() |
| 186 | + input.focus() |
| 187 | + }) |
| 188 | + list.append(b) |
| 189 | + } |
| 190 | + } else if (!entries) { |
| 191 | + list.append(el("p", "m-0 px-4 py-6 text-center text-[13px] text-muted", "Loading the index…")) |
| 192 | + } else { |
| 193 | + const result = search(entries, raw, { limit: 400 }) |
| 194 | + const groups = new Map<string, Indexed[]>() |
| 195 | + let shown = 0 |
| 196 | + for (const hit of result.hits) { |
| 197 | + const bucket = groups.get(hit.type) ?? [] |
| 198 | + if (bucket.length >= PER_GROUP || shown >= TOTAL) continue |
| 199 | + bucket.push(hit) |
| 200 | + groups.set(hit.type, bucket) |
| 201 | + shown++ |
| 202 | + } |
| 203 | + const ordered = [...groups.entries()].sort(([a], [b]) => (TYPE_RANK[a] ?? 9) - (TYPE_RANK[b] ?? 9)) |
| 204 | + for (const [type, hits] of ordered) { |
| 205 | + const count = result.perType[type] ?? hits.length |
| 206 | + list.append(heading(`${GROUP_LABEL[type] ?? type}${count > hits.length ? ` · ${count}` : ""}`)) |
| 207 | + for (const hit of hits) list.append(row(hit, result.query.terms)) |
| 208 | + } |
| 209 | + if (result.total === 0) { |
| 210 | + list.append(el("p", "m-0 px-4 py-6 text-center text-[13px] text-muted", "Nothing matches. Fewer words, or a different spelling?")) |
| 211 | + } |
| 212 | + if (result.total > shown || result.total > 0) { |
| 213 | + list.append(heading("")) |
| 214 | + list.append( |
| 215 | + linkRow( |
| 216 | + result.total > shown ? `All ${result.total} results` : "Open in search", |
| 217 | + `/search?q=${encodeURIComponent(raw)}`, |
| 218 | + "with filters and full descriptions", |
| 219 | + ), |
| 220 | + ) |
| 221 | + } |
| 222 | + } |
| 223 | + |
| 224 | + rows = Array.from(list.querySelectorAll<HTMLElement>(".palette-row")) |
| 225 | + selected = 0 |
| 226 | + select(0) |
| 227 | + } |
| 228 | + |
| 229 | + input.addEventListener("input", render) |
| 230 | + |
| 231 | + input.addEventListener("keydown", (event) => { |
| 232 | + if (event.key === "ArrowDown") { |
| 233 | + event.preventDefault() |
| 234 | + select(selected + 1) |
| 235 | + } else if (event.key === "ArrowUp") { |
| 236 | + event.preventDefault() |
| 237 | + select(selected - 1) |
| 238 | + } else if (event.key === "Enter") { |
| 239 | + event.preventDefault() |
| 240 | + const target = rows[selected] |
| 241 | + if (target) { |
| 242 | + rememberQuery(input.value) |
| 243 | + target.click() |
| 244 | + } else if (input.value.trim()) { |
| 245 | + rememberQuery(input.value) |
| 246 | + location.href = `/search?q=${encodeURIComponent(input.value.trim())}` |
| 247 | + } |
| 248 | + } |
| 249 | + }) |
| 250 | + |
| 251 | + dialog.addEventListener("close", () => { |
| 252 | + document.body.style.overflow = "" |
| 253 | + }) |
| 254 | + // A click on the backdrop lands on the dialog itself, not on the panel. |
| 255 | + dialog.addEventListener("click", (event) => { |
| 256 | + if (event.target === dialog) close() |
| 257 | + }) |
| 258 | + |
| 259 | + // The header field is a real form for no-JS readers; with JS it is the trigger. |
| 260 | + if (trigger) { |
| 261 | + trigger.addEventListener("focus", () => { |
| 262 | + trigger.blur() |
| 263 | + open() |
| 264 | + }) |
| 265 | + trigger.addEventListener("click", () => open()) |
| 266 | + } |
| 267 | + |
| 268 | + document.addEventListener("keydown", (event) => { |
| 269 | + const target = event.target as HTMLElement | null |
| 270 | + const typing = target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName) |
| 271 | + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { |
| 272 | + event.preventDefault() |
| 273 | + dialog.open ? close() : open() |
| 274 | + return |
| 275 | + } |
| 276 | + // `/` is the reflex on any reference site. On /search and /attributes it |
| 277 | + // goes to the page's own field, which has filters the palette does not. |
| 278 | + if (event.key === "/" && !typing && !event.metaKey && !event.ctrlKey && !event.altKey) { |
| 279 | + const field = document.getElementById("q") |
| 280 | + event.preventDefault() |
| 281 | + if (field instanceof HTMLInputElement) { |
| 282 | + field.focus() |
| 283 | + field.select() |
| 284 | + } else open() |
| 285 | + } |
| 286 | + }) |
| 287 | +</script> |
0 commit comments