| 1 | // fixtures/jump-to.mjs — the keyboard jump-to palette. |
| 2 | // |
| 3 | // Control-K (Command-K on a Mac) from anywhere signed in: type, and go |
| 4 | // straight to a document. Its own bundle, a few KB, because the list |
| 5 | // page should not download ProseMirror to offer a keyboard shortcut |
| 6 | // (fixtures/build-bundles.mjs, internal/docs/assets.go). |
| 7 | // |
| 8 | // # It is an addition, never the way in |
| 9 | // |
| 10 | // The list header carries a plain GET search form that works with no |
| 11 | // JavaScript at all (spec §6.2, and the search plan's D2). This makes |
| 12 | // finding a document *faster*; it does not make it *possible*. If this |
| 13 | // file fails to load, or throws, the app loses a shortcut and keeps |
| 14 | // search. |
| 15 | // |
| 16 | // # It does not rank |
| 17 | // |
| 18 | // Every query goes to GET /documents/search, which is the same |
| 19 | // Store.Search the list page renders through. Filtering a downloaded |
| 20 | // list of titles here would mean two rankers, and two rankers that |
| 21 | // disagree put the same document in different places while each looks |
| 22 | // correct on its own. One implementation cannot drift from itself. |
| 23 | // |
| 24 | // # The ARIA contract is implemented, not merely claimed |
| 25 | // |
| 26 | // The field is a combobox controlling a listbox. That says: Down and Up |
| 27 | // move through results, Enter opens the current one, Escape closes, |
| 28 | // and aria-activedescendant names which option is current. All of it is |
| 29 | // here, because a role written without the behaviour behind it is worse |
| 30 | // than no role — the person acts on it, arrow keys are what they try |
| 31 | // first, and silence reads as their own mistake. |
| 32 | // |
| 33 | // (That rule was learned on this app's formatting bar, which declared |
| 34 | // role="toolbar" and implemented none of its keyboard contract. It is |
| 35 | // written into AGENTS.md's accessibility conventions on the branch that |
| 36 | // fixed it, which may land before or after this one — so the reasoning |
| 37 | // is stated here rather than only cited, and this comment does not |
| 38 | // depend on which order they merge in.) |
| 39 | |
| 40 | const DEBOUNCE_MS = 120 |
| 41 | |
| 42 | export function createJumpTo(root) { |
| 43 | if (!root) return null |
| 44 | |
| 45 | const input = root.querySelector('[docs-jumpto-input]') |
| 46 | const list = root.querySelector('[docs-jumpto-list]') |
| 47 | const status = root.querySelector('[docs-jumpto-status]') |
| 48 | const closeButton = root.querySelector('[docs-jumpto-close]') |
| 49 | if (!input || !list || !status) return null |
| 50 | |
| 51 | // The copy is rendered into the page server-side (render.go's |
| 52 | // docs.jumpto.*) and read back from it here, so this bundle carries |
| 53 | // no strings of its own to keep in sync with the catalog by hand — |
| 54 | // the same rule the editor's save-status and the formatting bar |
| 55 | // follow. |
| 56 | const copy = { |
| 57 | empty: status.getAttribute('data-empty') || '', |
| 58 | failed: status.getAttribute('data-failed') || '', |
| 59 | partial: status.getAttribute('data-partial') || '', |
| 60 | } |
| 61 | |
| 62 | let open = false |
| 63 | let results = [] |
| 64 | let active = -1 |
| 65 | let timer = null |
| 66 | let sequence = 0 |
| 67 | // The element focus came from, so Escape can put it back. A palette |
| 68 | // that closes and drops focus on the body leaves a keyboard user at |
| 69 | // the top of the document with no idea where they were. |
| 70 | let returnFocusTo = null |
| 71 | |
| 72 | function show() { |
| 73 | if (open) return |
| 74 | open = true |
| 75 | returnFocusTo = document.activeElement |
| 76 | root.hidden = false |
| 77 | input.setAttribute('aria-expanded', 'true') |
| 78 | input.value = '' |
| 79 | render([]) |
| 80 | // The status has to be cleared with the field. Without this, a |
| 81 | // palette closed on "No documents match" reopens still saying it, |
| 82 | // over an empty query that has matched nothing yet — and because |
| 83 | // the status is a live region, a screen reader announces the stale |
| 84 | // answer to a question nobody has asked again. |
| 85 | setStatus('') |
| 86 | input.focus() |
| 87 | } |
| 88 | |
| 89 | function hide() { |
| 90 | if (!open) return |
| 91 | open = false |
| 92 | root.hidden = true |
| 93 | input.setAttribute('aria-expanded', 'false') |
| 94 | input.removeAttribute('aria-activedescendant') |
| 95 | clearTimeout(timer) |
| 96 | // Bump the sequence so a reply already in flight cannot render |
| 97 | // into a closed palette. |
| 98 | sequence++ |
| 99 | if (returnFocusTo && document.contains(returnFocusTo)) returnFocusTo.focus() |
| 100 | returnFocusTo = null |
| 101 | } |
| 102 | |
| 103 | function setStatus(text) { |
| 104 | status.textContent = text |
| 105 | status.hidden = text === '' |
| 106 | } |
| 107 | |
| 108 | function render(rows) { |
| 109 | results = rows |
| 110 | active = rows.length > 0 ? 0 : -1 |
| 111 | list.textContent = '' |
| 112 | for (let i = 0; i < rows.length; i++) { |
| 113 | const option = document.createElement('li') |
| 114 | option.id = `docs-jumpto-option-${i}` |
| 115 | option.setAttribute('role', 'option') |
| 116 | option.setAttribute('aria-selected', i === active ? 'true' : 'false') |
| 117 | option.dataset.href = rows[i].href |
| 118 | |
| 119 | const title = document.createElement('span') |
| 120 | title.textContent = rows[i].title |
| 121 | const when = document.createElement('span') |
| 122 | when.setAttribute('docs-jumpto-when', '') |
| 123 | when.textContent = rows[i].when |
| 124 | option.append(title, when) |
| 125 | |
| 126 | // mousedown rather than click, and prevented: the field must not |
| 127 | // lose focus before the navigation happens. |
| 128 | option.addEventListener('mousedown', (e) => { |
| 129 | e.preventDefault() |
| 130 | go(i) |
| 131 | }) |
| 132 | list.append(option) |
| 133 | } |
| 134 | reflectActive() |
| 135 | } |
| 136 | |
| 137 | function reflectActive() { |
| 138 | const options = list.children |
| 139 | for (let i = 0; i < options.length; i++) { |
| 140 | options[i].setAttribute('aria-selected', i === active ? 'true' : 'false') |
| 141 | } |
| 142 | if (active >= 0 && options[active]) { |
| 143 | input.setAttribute('aria-activedescendant', options[active].id) |
| 144 | // The active option has to be visible, not merely current: a |
| 145 | // list that scrolls and an aria-activedescendant that does not |
| 146 | // is a keyboard user reading nothing. |
| 147 | options[active].scrollIntoView({ block: 'nearest' }) |
| 148 | } else { |
| 149 | input.removeAttribute('aria-activedescendant') |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | function move(delta) { |
| 154 | if (results.length === 0) return |
| 155 | active = (active + delta + results.length) % results.length |
| 156 | reflectActive() |
| 157 | } |
| 158 | |
| 159 | function go(index) { |
| 160 | const row = results[index] |
| 161 | if (!row) return |
| 162 | window.location.href = row.href |
| 163 | } |
| 164 | |
| 165 | async function search(query) { |
| 166 | const mine = ++sequence |
| 167 | if (query.trim() === '') { |
| 168 | render([]) |
| 169 | setStatus('') |
| 170 | return |
| 171 | } |
| 172 | try { |
| 173 | const res = await fetch(`/documents/search?q=${encodeURIComponent(query)}`, { |
| 174 | credentials: 'same-origin', |
| 175 | }) |
| 176 | if (!res.ok) throw new Error(`search: ${res.status}`) |
| 177 | const rows = await res.json() |
| 178 | // A reply that arrived after a newer keystroke, or after the |
| 179 | // palette closed, must not overwrite what is on screen. |
| 180 | if (mine !== sequence || !open) return |
| 181 | render(rows) |
| 182 | setStatus(res.headers?.get('Docs-Catalog-Partial') === '1' ? copy.partial : (rows.length === 0 ? copy.empty : '')) |
| 183 | } catch (err) { |
| 184 | if (mine !== sequence || !open) return |
| 185 | // Said out loud rather than left as an empty list: "no results" |
| 186 | // and "the search did not happen" are different, and a person |
| 187 | // who cannot tell them apart concludes the document is gone. |
| 188 | render([]) |
| 189 | setStatus(copy.failed) |
| 190 | console.error(err) |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | input.addEventListener('input', () => { |
| 195 | clearTimeout(timer) |
| 196 | const query = input.value |
| 197 | timer = setTimeout(() => search(query), DEBOUNCE_MS) |
| 198 | }) |
| 199 | |
| 200 | input.addEventListener('keydown', (event) => { |
| 201 | // Never take a key that belongs to an input method. Mid-composition |
| 202 | // in Japanese, Chinese or Korean, Enter commits the conversion and |
| 203 | // Escape cancels it — so a palette that claims those keys navigates |
| 204 | // away while somebody is still typing the word they meant to search |
| 205 | // for, and closes when they try to take it back. This app declares |
| 206 | // twelve base locales (render.go), so that is not an edge case, it |
| 207 | // is most of them. |
| 208 | if (event.isComposing || event.keyCode === 229) return |
| 209 | switch (event.key) { |
| 210 | case 'ArrowDown': |
| 211 | event.preventDefault() |
| 212 | move(1) |
| 213 | break |
| 214 | case 'ArrowUp': |
| 215 | event.preventDefault() |
| 216 | move(-1) |
| 217 | break |
| 218 | case 'Home': |
| 219 | if (results.length) { |
| 220 | event.preventDefault() |
| 221 | active = 0 |
| 222 | reflectActive() |
| 223 | } |
| 224 | break |
| 225 | case 'End': |
| 226 | if (results.length) { |
| 227 | event.preventDefault() |
| 228 | active = results.length - 1 |
| 229 | reflectActive() |
| 230 | } |
| 231 | break |
| 232 | case 'Enter': |
| 233 | if (active >= 0) { |
| 234 | event.preventDefault() |
| 235 | go(active) |
| 236 | } |
| 237 | break |
| 238 | case 'Escape': |
| 239 | event.preventDefault() |
| 240 | hide() |
| 241 | break |
| 242 | case 'Tab': |
| 243 | // The palette is modal, and it holds exactly two focusable |
| 244 | // things: the field and its close button. Rather than a |
| 245 | // general focus trap — which is where a palette usually |
| 246 | // strands somebody — Tab simply closes it. Nothing behind the |
| 247 | // overlay is reachable while it is open, and Escape and Tab |
| 248 | // both lead out. |
| 249 | event.preventDefault() |
| 250 | hide() |
| 251 | break |
| 252 | default: |
| 253 | break |
| 254 | } |
| 255 | }) |
| 256 | |
| 257 | if (closeButton) { |
| 258 | closeButton.addEventListener('click', () => hide()) |
| 259 | } |
| 260 | |
| 261 | // A click on the backdrop closes it. Checked against the root |
| 262 | // itself so a click on the panel inside does not. |
| 263 | root.addEventListener('mousedown', (event) => { |
| 264 | if (event.target === root) hide() |
| 265 | }) |
| 266 | |
| 267 | addEventListener('keydown', (event) => { |
| 268 | if ((event.ctrlKey || event.metaKey) && (event.key === 'k' || event.key === 'K')) { |
| 269 | event.preventDefault() |
| 270 | if (open) hide() |
| 271 | else show() |
| 272 | return |
| 273 | } |
| 274 | if (event.key === 'Escape' && open) { |
| 275 | event.preventDefault() |
| 276 | hide() |
| 277 | } |
| 278 | }) |
| 279 | |
| 280 | // File → "Open a document…" is a link to the list, which is where it |
| 281 | // goes with scripts off. With the palette mounted it opens the palette |
| 282 | // instead: the item and ⌘K are one command (spec §7.3), and the key |
| 283 | // column beside it says so. |
| 284 | document.addEventListener('click', (event) => { |
| 285 | const item = event.target.closest && event.target.closest('a[data-docs-jumpto]') |
| 286 | if (!item || event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return |
| 287 | event.preventDefault() |
| 288 | const menu = item.closest('details') |
| 289 | if (menu) menu.open = false |
| 290 | show() |
| 291 | }) |
| 292 | |
| 293 | return { show, hide, isOpen: () => open } |
| 294 | } |
| 295 | |
| 296 | const mount = document.querySelector('[docs-jumpto]') |
| 297 | if (mount) { |
| 298 | try { |
| 299 | createJumpTo(mount) |
| 300 | } catch (err) { |
| 301 | // A palette that throws must not take the page with it: the search |
| 302 | // form in the list header is the real path to a document and does |
| 303 | // not depend on any of this. |
| 304 | console.error(err) |
| 305 | } |
| 306 | } |
| 307 | |