// fixtures/jump-to.mjs — the keyboard jump-to palette. // // Control-K (Command-K on a Mac) from anywhere signed in: type, and go // straight to a document. Its own bundle, a few KB, because the list // page should not download ProseMirror to offer a keyboard shortcut // (fixtures/build-bundles.mjs, internal/docs/assets.go). // // # It is an addition, never the way in // // The list header carries a plain GET search form that works with no // JavaScript at all (spec §6.2, and the search plan's D2). This makes // finding a document *faster*; it does not make it *possible*. If this // file fails to load, or throws, the app loses a shortcut and keeps // search. // // # It does not rank // // Every query goes to GET /documents/search, which is the same // Store.Search the list page renders through. Filtering a downloaded // list of titles here would mean two rankers, and two rankers that // disagree put the same document in different places while each looks // correct on its own. One implementation cannot drift from itself. // // # The ARIA contract is implemented, not merely claimed // // The field is a combobox controlling a listbox. That says: Down and Up // move through results, Enter opens the current one, Escape closes, // and aria-activedescendant names which option is current. All of it is // here, because a role written without the behaviour behind it is worse // than no role — the person acts on it, arrow keys are what they try // first, and silence reads as their own mistake. // // (That rule was learned on this app's formatting bar, which declared // role="toolbar" and implemented none of its keyboard contract. It is // written into AGENTS.md's accessibility conventions on the branch that // fixed it, which may land before or after this one — so the reasoning // is stated here rather than only cited, and this comment does not // depend on which order they merge in.) const DEBOUNCE_MS = 120 export function createJumpTo(root) { if (!root) return null const input = root.querySelector('[docs-jumpto-input]') const list = root.querySelector('[docs-jumpto-list]') const status = root.querySelector('[docs-jumpto-status]') const closeButton = root.querySelector('[docs-jumpto-close]') if (!input || !list || !status) return null // The copy is rendered into the page server-side (render.go's // docs.jumpto.*) and read back from it here, so this bundle carries // no strings of its own to keep in sync with the catalog by hand — // the same rule the editor's save-status and the formatting bar // follow. const copy = { empty: status.getAttribute('data-empty') || '', failed: status.getAttribute('data-failed') || '', partial: status.getAttribute('data-partial') || '', } let open = false let results = [] let active = -1 let timer = null let sequence = 0 // The element focus came from, so Escape can put it back. A palette // that closes and drops focus on the body leaves a keyboard user at // the top of the document with no idea where they were. let returnFocusTo = null function show() { if (open) return open = true returnFocusTo = document.activeElement root.hidden = false input.setAttribute('aria-expanded', 'true') input.value = '' render([]) // The status has to be cleared with the field. Without this, a // palette closed on "No documents match" reopens still saying it, // over an empty query that has matched nothing yet — and because // the status is a live region, a screen reader announces the stale // answer to a question nobody has asked again. setStatus('') input.focus() } function hide() { if (!open) return open = false root.hidden = true input.setAttribute('aria-expanded', 'false') input.removeAttribute('aria-activedescendant') clearTimeout(timer) // Bump the sequence so a reply already in flight cannot render // into a closed palette. sequence++ if (returnFocusTo && document.contains(returnFocusTo)) returnFocusTo.focus() returnFocusTo = null } function setStatus(text) { status.textContent = text status.hidden = text === '' } function render(rows) { results = rows active = rows.length > 0 ? 0 : -1 list.textContent = '' for (let i = 0; i < rows.length; i++) { const option = document.createElement('li') option.id = `docs-jumpto-option-${i}` option.setAttribute('role', 'option') option.setAttribute('aria-selected', i === active ? 'true' : 'false') option.dataset.href = rows[i].href const title = document.createElement('span') title.textContent = rows[i].title const when = document.createElement('span') when.setAttribute('docs-jumpto-when', '') when.textContent = rows[i].when option.append(title, when) // mousedown rather than click, and prevented: the field must not // lose focus before the navigation happens. option.addEventListener('mousedown', (e) => { e.preventDefault() go(i) }) list.append(option) } reflectActive() } function reflectActive() { const options = list.children for (let i = 0; i < options.length; i++) { options[i].setAttribute('aria-selected', i === active ? 'true' : 'false') } if (active >= 0 && options[active]) { input.setAttribute('aria-activedescendant', options[active].id) // The active option has to be visible, not merely current: a // list that scrolls and an aria-activedescendant that does not // is a keyboard user reading nothing. options[active].scrollIntoView({ block: 'nearest' }) } else { input.removeAttribute('aria-activedescendant') } } function move(delta) { if (results.length === 0) return active = (active + delta + results.length) % results.length reflectActive() } function go(index) { const row = results[index] if (!row) return window.location.href = row.href } async function search(query) { const mine = ++sequence if (query.trim() === '') { render([]) setStatus('') return } try { const res = await fetch(`/documents/search?q=${encodeURIComponent(query)}`, { credentials: 'same-origin', }) if (!res.ok) throw new Error(`search: ${res.status}`) const rows = await res.json() // A reply that arrived after a newer keystroke, or after the // palette closed, must not overwrite what is on screen. if (mine !== sequence || !open) return render(rows) setStatus(res.headers?.get('Docs-Catalog-Partial') === '1' ? copy.partial : (rows.length === 0 ? copy.empty : '')) } catch (err) { if (mine !== sequence || !open) return // Said out loud rather than left as an empty list: "no results" // and "the search did not happen" are different, and a person // who cannot tell them apart concludes the document is gone. render([]) setStatus(copy.failed) console.error(err) } } input.addEventListener('input', () => { clearTimeout(timer) const query = input.value timer = setTimeout(() => search(query), DEBOUNCE_MS) }) input.addEventListener('keydown', (event) => { // Never take a key that belongs to an input method. Mid-composition // in Japanese, Chinese or Korean, Enter commits the conversion and // Escape cancels it — so a palette that claims those keys navigates // away while somebody is still typing the word they meant to search // for, and closes when they try to take it back. This app declares // twelve base locales (render.go), so that is not an edge case, it // is most of them. if (event.isComposing || event.keyCode === 229) return switch (event.key) { case 'ArrowDown': event.preventDefault() move(1) break case 'ArrowUp': event.preventDefault() move(-1) break case 'Home': if (results.length) { event.preventDefault() active = 0 reflectActive() } break case 'End': if (results.length) { event.preventDefault() active = results.length - 1 reflectActive() } break case 'Enter': if (active >= 0) { event.preventDefault() go(active) } break case 'Escape': event.preventDefault() hide() break case 'Tab': // The palette is modal, and it holds exactly two focusable // things: the field and its close button. Rather than a // general focus trap — which is where a palette usually // strands somebody — Tab simply closes it. Nothing behind the // overlay is reachable while it is open, and Escape and Tab // both lead out. event.preventDefault() hide() break default: break } }) if (closeButton) { closeButton.addEventListener('click', () => hide()) } // A click on the backdrop closes it. Checked against the root // itself so a click on the panel inside does not. root.addEventListener('mousedown', (event) => { if (event.target === root) hide() }) addEventListener('keydown', (event) => { if ((event.ctrlKey || event.metaKey) && (event.key === 'k' || event.key === 'K')) { event.preventDefault() if (open) hide() else show() return } if (event.key === 'Escape' && open) { event.preventDefault() hide() } }) // File → "Open a document…" is a link to the list, which is where it // goes with scripts off. With the palette mounted it opens the palette // instead: the item and ⌘K are one command (spec §7.3), and the key // column beside it says so. document.addEventListener('click', (event) => { const item = event.target.closest && event.target.closest('a[data-docs-jumpto]') if (!item || event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return event.preventDefault() const menu = item.closest('details') if (menu) menu.open = false show() }) return { show, hide, isOpen: () => open } } const mount = document.querySelector('[docs-jumpto]') if (mount) { try { createJumpTo(mount) } catch (err) { // A palette that throws must not take the page with it: the search // form in the list header is the real path to a document and does // not depend on any of this. console.error(err) } }