Public: anyone can read this; the crew can change it.

A `:` palette jumps to any page by name, from the keyboard alone #10648

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/10434-a-palette-jumps-to-any-page-by-name-from → main 6 commits fork 7 threads · 7 unresolved

  • Merged. scout/10434-a-palette-jumps-to-any-page-by-name-from was merged into main as 2e50748.

Review

Sign in to review this change.
8 files changed +273 −16
docs/design/areas/dialog.md +4
@@ -12,3 +12,7 @@ limb page's confirm form (`limbPreviewPage()` in `src/web/pages/limb.ts`) both d
1212 The confirm page before a destructive route (`confirmPage()` in `src/web/pages/search.ts`) is not a dialog: it is a page
1313 heading, the one sentence of what will happen, the typed phrase when needed, and the action beside a way back, with no box
1414 and no new class.
15
16 ## The `:` go-to prompt (#10434)
17
18 The prompt is the command palette's dialog (`.dialog.palette`, `.palette-input`, `.palette-list`, `.palette-item`, `.palette-foot`) with a combobox input over a listbox of `div` options; the leading match carries `aria-selected="true"` and the input's `aria-activedescendant` points at it. No new component class. One rule is added, in `src/web/style/components.ts`: `.palette-item[hidden] { display: none; }`, because `.palette-item` sets `display: flex`, which would otherwise show an option the filter hid. The list keeps its fixed height, so filtering moves nothing. The "no match" line is the `.palette-foot` text, announced as a status.
docs/features/hotkey-hints.md +10
@@ -16,3 +16,13 @@ Every action with a key shows it without taking room. Hints are never a box in t
1616 - Test: `test/hotkey-hints.test.ts`. It checks the stylesheet's rules and the rendered markup. It does not measure a rendered layout, because the test run has no layout engine.
1717
1818 **Terminal:** none. Terminal screens already print their keys in their footer line, which is always visible, so there is nothing to reveal.
19
20 **`:` go to a page (#10434)**

Say what is true about help (it reads the script's data, not markup), and give the Terminal line for this key.

21 - `:` outside a field opens one line; typing narrows the forge's top-level pages (`src/web/go_pages.ts`, the one list; the Cmd/Ctrl K palette's "Go to" rows are built from the same list), Enter opens the leading match (a name that starts with what you typed beats one that contains it, ties in list order), Esc closes. With no match the line under the box says "No page called that" and Enter does nothing. It reuses the palette's dialog classes: no new component, and the list has a fixed height so nothing shifts.
22 - Screen readers: the input is a combobox over a listbox; `aria-activedescendant` names the leading match.
23 - `?` help lists the same pages: it reads `gotoPages()` in the keys script (the list the script carries, cut to the viewer's level), not the prompt's markup, which is drawn only when `:` is first pressed.
24 - Inside a field `:` types a colon; inside the open prompt the dialog owns the keys.
25 - Touch: `:` needs a keyboard. On a phone the way in is the command palette button in the top bar, whose "Go to" rows are the same pages; no second touch control was added.
26 - **Terminal:** none. The terminal screens print their keys in their footer line and have no `:` prompt; their pages are reached by their own menus.
27 - Left out: pages that belong to a repository (waves live at `/:owner/:name/waves`), so they are not top-level names.
28 - Test: `test/keys-palette.test.ts`.
src/web/client.ts +83 −2
@@ -10,6 +10,7 @@
1010 * behaviour that a page depends on to be usable.
1111 */
1212
13 import { GO_MATCH_JS, goPagesData } from "./go_pages.ts";
1314 import { actNowJs } from "./act_now.ts";
1415 import { pickJs } from "./pick.ts";
1516 import { imageViewJs } from "./render/image.ts";
@@ -1567,13 +1568,13 @@ const shellCore = /* js */ `
15671568 // First press lays the keys over the page; the second clears them and opens the help.
15681569 if (doc.documentElement.hasAttribute('data-hints')) { hints(false); openHelp(); } else hints(true);
15691570 }
1570 else if (e.key === ':') { seq.length = 0; e.preventDefault(); openPalette(); }
1571 else if (e.key === ':') { seq.length = 0; e.preventDefault(); openGoto(); }
15711572 });
15721573
15731574 // ---- help overlay, generated from the shortcuts actually on the page ----
15741575 var STATIC_KEYS = [
15751576 ['j / k', 'Move between rows'], ['o or Enter', 'Open the focused row'],
1576 ['/', 'Focus search'], ['Cmd/Ctrl K', 'Command palette'], [':', 'Command palette'], ['?', 'This help'],
1577 ['/', 'Focus search'], ['Cmd/Ctrl K', 'Command palette'], [':', 'Go to a page by name'], ['?', 'This help'],
15771578 ['Esc', 'Leave a box, close a menu or dialog'],
15781579 ];
15791580 // The rows a page earns by what it carries: a box to type in, a form
@@ -1622,6 +1623,14 @@ const shellCore = /* js */ `
16221623 tbody.appendChild(keyRow(k, label));
16231624 });
16241625 pageKeys().concat(STATIC_KEYS).forEach(function (p) { tbody.appendChild(keyRow(p[0], p[1])); });
1626 // The pages \`:\` reaches, read from the prompt's own list so the two cannot differ.
1627 var names = gotoPages().map(function (p) { return p[0]; });
1628 if (names.length) {
1629 var pr = doc.createElement('tr');
1630 var pk = doc.createElement('td'); var pkb = doc.createElement('kbd'); pkb.textContent = ':'; pk.appendChild(pkb);
1631 var pl = doc.createElement('td'); pl.textContent = 'Pages: ' + names.join(', ');
1632 pr.appendChild(pk); pr.appendChild(pl); tbody.appendChild(pr);
1633 }
16251634 table.appendChild(tbody);
16261635 if (body) { body.textContent = ''; body.appendChild(table); }
16271636 openModal(dlg);
@@ -1816,6 +1825,78 @@ const shellCore = /* js */ `
18161825 }).catch(function () { say('danger', '${NOT_TOLD_WORDS}'); });
18171826 }
18181827
1828 // ---- go to a page: one line, the server's own list filtered in place ----
1829 var closeGoto = null;
1830 // The pages ride in this script (cached once); the page wears only the viewer's level in data-go.
1831 var GO_PAGES = ${JSON.stringify(goPagesData())};
1832 function gotoPages() {
1833 var lv = +(doc.body.getAttribute('data-go') || 0);
1834 return GO_PAGES.filter(function (p) { return p[2] <= lv; });
1835 }
1836 ${GO_MATCH_JS}
1837 var GOTO_HINT = 'Enter opens the first, Esc closes';
1838 function gotoDialog() {
1839 var dlg = doc.getElementById('goto');
1840 if (dlg) return dlg;
1841 dlg = doc.createElement('dialog');
1842 dlg.id = 'goto'; dlg.className = 'dialog palette'; dlg.setAttribute('aria-label', 'Go to a page');
outdated

Principle 8 / a11y: the placeholder ": page name…" is odd. The input has no combobox role or aria-controls / aria-activedescendant.

1843 var input = doc.createElement('input');
1844 input.className = 'input palette-input'; input.type = 'text'; input.placeholder = 'Page name';
1845 input.setAttribute('role', 'combobox'); input.setAttribute('aria-label', 'Go to a page');
1846 input.setAttribute('aria-controls', 'goto-list'); input.setAttribute('aria-expanded', 'true');
outdated

Anchors with role=option inside a listbox is wrong semantics. Mark the selected match through the input's activedescendant.

1847 input.setAttribute('aria-autocomplete', 'list'); input.setAttribute('autocomplete', 'off');
1848 input.setAttribute('autocapitalize', 'off'); input.spellcheck = false;
1849 var box = doc.createElement('div'); box.className = 'palette-list'; box.id = 'goto-list';
1850 box.setAttribute('role', 'listbox'); box.setAttribute('aria-label', 'Pages');
1851 gotoPages().forEach(function (p, i) {
1852 var a = doc.createElement('div'); a.className = 'palette-item'; a.setAttribute('role', 'option');
1853 a.id = 'goto-' + i; a.setAttribute('aria-selected', 'false'); a.setAttribute('data-href', p[1]);
1854 var sp = doc.createElement('span'); sp.className = 'grow'; sp.textContent = p[0]; a.appendChild(sp); box.appendChild(a);
1855 });
1856 var foot = doc.createElement('div'); foot.className = 'palette-foot'; foot.id = 'goto-foot';
1857 foot.setAttribute('role', 'status'); foot.textContent = GOTO_HINT;
1858 dlg.appendChild(input); dlg.appendChild(box); dlg.appendChild(foot);
1859 doc.body.appendChild(dlg);
1860 return dlg;
1861 }
1862 function openGoto() {
1863 var dlg = gotoDialog();
1864 if (!dlg.showModal) { location.href = '/search'; return; }
1865 if (closeGoto) { closeGoto(); return; }
1866 var input = dlg.querySelector('input');
1867 var foot = dlg.querySelector('#goto-foot');
1868 var pages = gotoPages();
1869 var opts = Array.prototype.slice.call(dlg.querySelectorAll('[role=option]'));
1870 var lead = null;
1871 function paint() {
1872 var hits = goMatch(pages, input.value);
1873 lead = hits.length ? opts[pages.indexOf(hits[0])] : null;
1874 opts.forEach(function (o, i) {
1875 o.hidden = hits.indexOf(pages[i]) < 0;
1876 o.setAttribute('aria-selected', o === lead ? 'true' : 'false');
1877 });
1878 if (lead) input.setAttribute('aria-activedescendant', lead.id); else input.removeAttribute('aria-activedescendant');
1879 foot.textContent = hits.length ? GOTO_HINT : 'No page called that';
1880 }
1881 function go(o) { location.assign(o.getAttribute('data-href')); }
1882 function onKey(e) {
1883 if (e.key !== 'Enter' || e.isComposing) return;
outdated

With no match, Enter does nothing and nothing is said. Show an empty line in plain words.

1884 e.preventDefault();
1885 if (lead) go(lead);
1886 }
1887 function onClick(e) { var o = e.target.closest && e.target.closest('[role=option]'); if (o) go(o); }
1888 input.value = '';
1889 paint();
1890 closeGoto = openModal(dlg, function () {
1891 closeGoto = null; dlg.removeEventListener('keydown', onKey);
1892 dlg.removeEventListener('click', onClick); input.removeEventListener('input', paint);
1893 });
1894 dlg.addEventListener('keydown', onKey);
1895 dlg.addEventListener('click', onClick);
1896 input.addEventListener('input', paint);
1897 input.focus();
1898 }
1899
18191900 var closePalette = null;
18201901 function openPalette() {
18211902 var dlg = doc.getElementById('palette');
src/web/go_pages.ts added +81
@@ -0,0 +1,81 @@
1 /**
2 * The forge's top-level pages, by name, as plain data: what the `:` prompt
3 * filters, what `?` help lists, and (through `palette`) the "Go to" rows of
4 * the Cmd/Ctrl K palette, which `layout.ts` builds from this list rather than
5 * keeping its own. One list; a page added here appears in all three.
6 */
7
8 export interface GoPage {
9 /** The name typed to reach it. */
10 readonly name: string;
11 readonly href: string;
12 /** Who is shown it: anyone, a signed-in person, or a site admin. */
13 readonly who: "all" | "user" | "admin";
14 /** Present when the Cmd/Ctrl K palette also lists it as a "Go to" row, in its longer wording. */
15 readonly palette?: string;
16 }
outdated

Box 2: this is a second hand-written list, not one built from the nav. Derive it from the nav data.

17
18 export const GO_PAGES: readonly GoPage[] = [
19 { name: "Home", href: "/", who: "all", palette: "Home" },
20 { name: "Apps", href: "/apps", who: "all", palette: "Apps" },
21 { name: "Explore", href: "/explore", who: "all", palette: "Explore" },
22 { name: "Packs", href: "/packs", who: "all", palette: "Forge packs" },
23 { name: "Front page", href: "/front", who: "all", palette: "Front page" },
24 { name: "Search", href: "/search", who: "all", palette: "Search" },
25 { name: "Feed", href: "/feed", who: "user" },
26 { name: "Board", href: "/board", who: "user" },
27 { name: "Boards", href: "/boards", who: "user" },
28 { name: "Flow", href: "/flow", who: "user" },
29 { name: "Asks", href: "/asks", who: "user" },
30 { name: "Asked", href: "/asked", who: "user", palette: "What you asked for" },
31 { name: "Notes", href: "/notes", who: "user", palette: "Notes" },
32 { name: "Talk", href: "/talk", who: "user", palette: "Talk to the forge" },
33 { name: "Map", href: "/map", who: "user", palette: "Map" },
34 { name: "Displays", href: "/displays", who: "user", palette: "Displays" },
35 { name: "Settings", href: "/settings", who: "user" },
36 { name: "System", href: "/system", who: "admin" },
37 { name: "Releases", href: "/releases", who: "admin" },
38 { name: "Forges", href: "/forges", who: "admin" },
39 { name: "Family", href: "/family", who: "admin" },
40 { name: "Audit", href: "/audit", who: "admin" },
41 ];
42
43 /** The pages this viewer may be sent to, in list order. */
44 export function goPagesFor(viewer: { signedIn: boolean; admin: boolean }): GoPage[] {
45 return GO_PAGES.filter(
46 (p) => p.who === "all" || (viewer.signedIn && (p.who === "user" || viewer.admin)),
47 );
48 }
49
50 /** The one digit the page wears so the keys script can pick its pages: 0 a stranger, 1 signed in, 2 admin. */
51 export function goLevel(viewer: { signedIn: boolean; admin: boolean }): 0 | 1 | 2 {
52 return !viewer.signedIn ? 0 : viewer.admin ? 2 : 1;
53 }
54
55 /** The list as the keys script carries it: [name, href, level needed]. */
56 export function goPagesData(): [string, string, number][] {
57 return GO_PAGES.map((p) => [p.name, p.href, p.who === "all" ? 0 : p.who === "user" ? 1 : 2]);
58 }
59
60 /** The Cmd/Ctrl K palette's "Go to" rows, in list order, and which of them a stranger may see. */
61 export function goPaletteRows(): { t: string; h: string; visitor: boolean }[] {
62 return GO_PAGES.filter((p) => p.palette !== undefined).map((p) => ({
63 t: p.palette as string,
64 h: p.href,
65 visitor: p.who === "all",
66 }));
67 }
68
69 /**
70 * The filter, as the JavaScript the keys script ships: `goMatch(pages, q)`
71 * takes [name, href, level] rows and returns those whose lower-cased name
72 * contains the trimmed, lower-cased query, names that start with it first,
73 * ties in list order. The first element is what Enter opens. It lives here as
74 * source so the test can run the very code the browser runs.
75 */
76 export const GO_MATCH_JS = `function goMatch(pages, q) {
77 q = String(q).trim().toLowerCase();
78 var hits = pages.filter(function (p) { return q === '' || p[0].toLowerCase().indexOf(q) >= 0; });
79 var starts = hits.filter(function (p) { return p[0].toLowerCase().indexOf(q) === 0; });
80 return starts.concat(hits.filter(function (p) { return starts.indexOf(p) < 0; }));
81 }`;
src/web/layout.ts +4 −13
@@ -7,6 +7,7 @@
77 * `/search`), and every dialog (a link to a real page).
88 */
99
10 import { goLevel, goPaletteRows } from "./go_pages.ts";
1011 import { NEW_ISSUE_KEY } from "./script_keys.ts";
1112 import { newIssueHref } from "./issue_from.ts";
1213 import { OPENSEARCH_PATH, OPENSEARCH_TITLE, OPENSEARCH_TYPE } from "./opensearch.ts";
@@ -393,21 +394,11 @@ function searchBar(): Html {
393394
394395 /** The palette's own verbs: where to go, what to make, what to set. */
395396 const STATIC_PALETTE: PaletteItem[] = [
396 { t: "Home", h: "/", c: "Go to" },
397 { t: "Apps", h: "/apps", c: "Go to" },
398 { t: "Explore", h: "/explore", c: "Go to" },
399 { t: "Forge packs", h: "/packs", c: "Go to" },
400 { t: "Displays", h: "/displays", c: "Go to" },
401 { t: "Map", h: "/map", c: "Go to" },
402 { t: "What you asked for", h: "/asked", c: "Go to" },
403 { t: "Front page", h: "/front", c: "Go to" },
397 ...goPaletteRows().map((r) => ({ t: r.t, h: r.h, c: "Go to" })),
404398 { t: "New app", h: "/apps/new", c: "Create" },
405399 { t: "New repository", h: "/new", c: "Create" },
406400 { t: "Migrate a repository", h: "/migrate", c: "Create" },
407401 { t: "New organization", h: "/organizations/new", c: "Create" },
408 { t: "Notes", h: "/notes", c: "Go to" },
409 { t: "Talk to the forge", h: "/talk", c: "Go to" },
410 { t: "Search", h: "/search", c: "Go to" },
411402 { t: "Account settings", h: "/settings", c: "Settings" },
412403 { t: "Access tokens", h: "/settings/tokens", c: "Settings" },
413404 { t: "SSH keys", h: "/settings/keys", c: "Settings" },
@@ -416,7 +407,7 @@ const STATIC_PALETTE: PaletteItem[] = [
416407 ];
417408
418409 /** What a signed-out visitor can reach: places to go, not Create and Settings rows that end at a sign-in wall. */
419 const VISITOR_PALETTE = new Set(["Home", "Apps", "Explore", "Forge packs", "Front page", "Search"]);
410 const VISITOR_PALETTE = new Set(goPaletteRows().filter((r) => r.visitor).map((r) => r.t));
420411
421412 /** First path segments that are the forge's own pages, never an owner: no repository read for them. */
422413 const NOT_AN_OWNER = new Set([
@@ -882,7 +873,7 @@ export function page(options: PageOptions, status = 200): Response {
882873 ? raw(`<style>${options.styleCss}</style>`)
883874 : null}
884875 </head>
885 <body>
876 <body data-go="${goLevel({ signedIn: ctx.auth.user !== undefined, admin: ctx.auth.user?.isAdmin === true })}">
886877 ${shell}
887878 <script src="${shellAsset().path}" defer></script>
888879 <script src="${beaconAsset().path}" defer></script>
src/web/style/components.ts +1
@@ -792,6 +792,7 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); }
792792 background: none; color: var(--fg); font-size: 14px; text-align: left;
793793 text-decoration: none; cursor: pointer;
794794 }
795 .palette-item[hidden] { display: none; }

Box 3: put the style in docs/design/areas/ as the issue asked.

795796 .palette-item .grow { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
796797 .palette-item .ico { color: var(--faint); }
797798 .palette-item .cat {
test/help-keys.test.ts +1 −1
@@ -52,5 +52,5 @@ test('every key STATIC_KEYS names is answered by the handler', () => {
5252
5353 test(': opens the palette and is listed', () => {
5454 expect(handler).toContain("e.key === ':'");
55 expect(src).toContain("[':', 'Command palette']");
55 expect(src).toContain("[':', 'Go to a page by name']");
5656 });
test/keys-palette.test.ts added +89
@@ -0,0 +1,89 @@
1 /** `:` opens a one-line go-to-page prompt built from one list of pages (#10434). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { readFileSync } from "node:fs";
4 import { snappyFixture, type SnappyFixture } from "../bench/snappy.ts";
5 import { GO_MATCH_JS, GO_PAGES, goLevel, goPagesData, goPagesFor, goPaletteRows } from "../src/web/go_pages.ts";
6
7 let f: SnappyFixture;
8 beforeAll(async () => {
9 f = await snappyFixture();
10 }, 120_000);
11 afterAll(async () => {
12 await f.close();
13 });
14
15 type Row = [string, string, number];
16 // The very source the browser receives, run here as a function.
17 const goMatch = new Function(`${GO_MATCH_JS}; return goMatch;`)() as (pages: Row[], q: string) => Row[];
18 const all = goPagesData();
19 const hit = (q: string) => goMatch(all, q).map((p) => p[0]);
20 const names = (v: { signedIn: boolean; admin: boolean }) => goPagesFor(v).map((p) => p.name);
21
22 test("the page list is plain data, scoped by who is looking", () => {
23 expect(names({ signedIn: false, admin: false })).toEqual(["Home", "Apps", "Explore", "Packs", "Front page", "Search"]);
24 const user = names({ signedIn: true, admin: false });
25 for (const n of ["Board", "Flow", "Feed"]) expect(user).toContain(n);
26 expect(user).not.toContain("System");
27 const admin = names({ signedIn: true, admin: true });
28 for (const n of ["System", "Releases"]) expect(admin).toContain(n);
29 expect(new Set(GO_PAGES.map((p) => p.href)).size).toBe(GO_PAGES.length);
30 expect(goLevel({ signedIn: false, admin: false })).toBe(0);
31 expect(goLevel({ signedIn: true, admin: true })).toBe(2);
32 });
33
34 test("the filter: substring match, case and space ignored, a prefix beats a substring", () => {
35 expect(hit("flow")).toEqual(["Flow"]);
36 expect(hit(" FLOW ")).toEqual(["Flow"]);
37 expect(hit("")).toEqual(all.map((p) => p[0]));
38 // "bo" starts Board and Boards; "Notes"/"Talk" etc. do not contain it.
39 expect(hit("bo").slice(0, 2)).toEqual(["Board", "Boards"]);
40 // "s" starts Search, Settings, System; "Apps" and "Asks" only contain it, so they follow.
41 const s = hit("s");
outdated

This test only greps source text. It would pass with a broken filter. Test the matching and ranking as behaviour.

42 expect(s.indexOf("System")).toBeLessThan(s.indexOf("Apps"));
43 expect(goMatch(all, "s")[0]![0]).toBe("Search");
44 // a contained, not leading, match still counts
45 expect(hit("rds")).toEqual(["Boards"]);
46 });
47
48 test("no match is an empty list, so Enter has nothing to open", () => {
49 expect(hit("zzzz")).toEqual([]);
50 });
51
52 test("the Cmd/Ctrl K palette's Go to rows come from the same list", () => {
53 const rows = goPaletteRows();
54 for (const r of rows) expect(GO_PAGES.some((p) => p.href === r.h)).toBe(true);
55 expect(rows.filter((r) => r.visitor).map((r) => r.t)).toContain("Forge packs");
56 });
57
58 test("every listed page answers: no href is a dead route", async () => {
59 for (const p of GO_PAGES) {
60 const r = await fetch(`${f.base}${p.href}`, { headers: { cookie: f.ownerCookie }, redirect: "manual" });
61 expect(r.status, p.href).not.toBe(404);
62 expect(r.status, p.href).toBeLessThan(500);
63 }
64 });
65
66 test("a page wears only the viewer's level; no prompt markup is drawn until : is pressed", async () => {
67 const owner = await (await fetch(`${f.base}/`, { headers: { cookie: f.ownerCookie } })).text();
68 expect(owner).toMatch(/<body[^>]*data-go="[12]"/);
69 expect(owner).not.toContain('id="goto"');
70 const anon = await (await fetch(`${f.base}/`)).text();
71 expect(anon).toMatch(/<body[^>]*data-go="0"/);
72 expect(anon).not.toContain('href="/board"');
73 });
74
75 test("the keys script: : opens the combobox prompt, announces the lead, says when nothing matches", async () => {
76 const page = await (await fetch(`${f.base}/`)).text();
77 const path = /src="([^"]*\.js[^"]*)"/.exec(page.slice(page.indexOf("<body")))![1]!;
78 const js = await (await fetch(`${f.base}${path}`)).text();
79 expect(js).toContain("e.key === ':'");
80 expect(js).toMatch(/e\.key === ':'\) \{ seq\.length = 0; e\.preventDefault\(\); openGoto\(\)/);
81 expect(js).toContain("function goMatch(pages, q)");
82 for (const a of ["'combobox'", "'aria-controls', 'goto-list'", "aria-activedescendant", "'listbox'", "'option'"]) expect(js).toContain(a);
83 expect(js).not.toContain("data-goto");
84 expect(js).toContain("No page called that");
85 expect(js).toContain('"Flow","/flow",1');
86 // The field guard returns before the key chain, so a colon typed in a box is a colon.
87 const src = readFileSync(new URL("../src/web/client.ts", import.meta.url), "utf8");
88 expect(src.indexOf("t.matches('input, textarea, select, [contenteditable]')")).toBeLessThan(src.indexOf("openGoto();"));
89 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs