oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

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
40const DEBOUNCE_MS = 120
41
42export 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
296const mount = document.querySelector('[docs-jumpto]')
297if (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