Public: anyone can read this; the crew can change it.
A `:` palette jumps to any page by name, from the keyboard alone #10648
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10434-a-palette-jumps-to-any-page-by-name-from → main 6 commits fork 7 threads · 7 unresolved
docs/design/areas/dialog.md +4
| @@ -12,3 +12,7 @@ limb page's confirm form (`limbPreviewPage()` in `src/web/pages/limb.ts`) both d | ||
| 12 | 12 | The confirm page before a destructive route (`confirmPage()` in `src/web/pages/search.ts`) is not a dialog: it is a page |
| 13 | 13 | heading, the one sentence of what will happen, the typed phrase when needed, and the action beside a way back, with no box |
| 14 | 14 | 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 | ||
| 16 | 16 | - 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. |
| 17 | 17 | |
| 18 | 18 | **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)** | |
| 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 @@ | ||
| 10 | 10 | * behaviour that a page depends on to be usable. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import { GO_MATCH_JS, goPagesData } from "./go_pages.ts"; | |
| 13 | 14 | import { actNowJs } from "./act_now.ts"; |
| 14 | 15 | import { pickJs } from "./pick.ts"; |
| 15 | 16 | import { imageViewJs } from "./render/image.ts"; |
| @@ -1567,13 +1568,13 @@ const shellCore = /* js */ ` | ||
| 1567 | 1568 | // First press lays the keys over the page; the second clears them and opens the help. |
| 1568 | 1569 | if (doc.documentElement.hasAttribute('data-hints')) { hints(false); openHelp(); } else hints(true); |
| 1569 | 1570 | } |
| 1570 | else if (e.key === ':') { seq.length = 0; e.preventDefault(); openPalette(); } | |
| 1571 | else if (e.key === ':') { seq.length = 0; e.preventDefault(); openGoto(); } | |
| 1571 | 1572 | }); |
| 1572 | 1573 | |
| 1573 | 1574 | // ---- help overlay, generated from the shortcuts actually on the page ---- |
| 1574 | 1575 | var STATIC_KEYS = [ |
| 1575 | 1576 | ['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'], | |
| 1577 | 1578 | ['Esc', 'Leave a box, close a menu or dialog'], |
| 1578 | 1579 | ]; |
| 1579 | 1580 | // The rows a page earns by what it carries: a box to type in, a form |
| @@ -1622,6 +1623,14 @@ const shellCore = /* js */ ` | ||
| 1622 | 1623 | tbody.appendChild(keyRow(k, label)); |
| 1623 | 1624 | }); |
| 1624 | 1625 | 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 | } | |
| 1625 | 1634 | table.appendChild(tbody); |
| 1626 | 1635 | if (body) { body.textContent = ''; body.appendChild(table); } |
| 1627 | 1636 | openModal(dlg); |
| @@ -1816,6 +1825,78 @@ const shellCore = /* js */ ` | ||
| 1816 | 1825 | }).catch(function () { say('danger', '${NOT_TOLD_WORDS}'); }); |
| 1817 | 1826 | } |
| 1818 | 1827 | |
| 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 |
||
| 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 | ||
| 1819 | 1900 | var closePalette = null; |
| 1820 | 1901 | function openPalette() { |
| 1821 | 1902 | 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 @@ | ||
| 7 | 7 | * `/search`), and every dialog (a link to a real page). |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { goLevel, goPaletteRows } from "./go_pages.ts"; | |
| 10 | 11 | import { NEW_ISSUE_KEY } from "./script_keys.ts"; |
| 11 | 12 | import { newIssueHref } from "./issue_from.ts"; |
| 12 | 13 | import { OPENSEARCH_PATH, OPENSEARCH_TITLE, OPENSEARCH_TYPE } from "./opensearch.ts"; |
| @@ -393,21 +394,11 @@ function searchBar(): Html { | ||
| 393 | 394 | |
| 394 | 395 | /** The palette's own verbs: where to go, what to make, what to set. */ |
| 395 | 396 | 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" })), | |
| 404 | 398 | { t: "New app", h: "/apps/new", c: "Create" }, |
| 405 | 399 | { t: "New repository", h: "/new", c: "Create" }, |
| 406 | 400 | { t: "Migrate a repository", h: "/migrate", c: "Create" }, |
| 407 | 401 | { 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" }, | |
| 411 | 402 | { t: "Account settings", h: "/settings", c: "Settings" }, |
| 412 | 403 | { t: "Access tokens", h: "/settings/tokens", c: "Settings" }, |
| 413 | 404 | { t: "SSH keys", h: "/settings/keys", c: "Settings" }, |
| @@ -416,7 +407,7 @@ const STATIC_PALETTE: PaletteItem[] = [ | ||
| 416 | 407 | ]; |
| 417 | 408 | |
| 418 | 409 | /** 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)); | |
| 420 | 411 | |
| 421 | 412 | /** First path segments that are the forge's own pages, never an owner: no repository read for them. */ |
| 422 | 413 | const NOT_AN_OWNER = new Set([ |
| @@ -882,7 +873,7 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 882 | 873 | ? raw(`<style>${options.styleCss}</style>`) |
| 883 | 874 | : null} |
| 884 | 875 | </head> |
| 885 | <body> | |
| 876 | <body data-go="${goLevel({ signedIn: ctx.auth.user !== undefined, admin: ctx.auth.user?.isAdmin === true })}"> | |
| 886 | 877 | ${shell} |
| 887 | 878 | <script src="${shellAsset().path}" defer></script> |
| 888 | 879 | <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); } | ||
| 792 | 792 | background: none; color: var(--fg); font-size: 14px; text-align: left; |
| 793 | 793 | text-decoration: none; cursor: pointer; |
| 794 | 794 | } |
| 795 | .palette-item[hidden] { display: none; } | |
Box 3: put the style in docs/design/areas/ as the issue asked. |
||
| 795 | 796 | .palette-item .grow { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 796 | 797 | .palette-item .ico { color: var(--faint); } |
| 797 | 798 | .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', () => { | ||
| 52 | 52 | |
| 53 | 53 | test(': opens the palette and is listed', () => { |
| 54 | 54 | expect(handler).toContain("e.key === ':'"); |
| 55 | expect(src).toContain("[':', 'Command palette']"); | |
| 55 | expect(src).toContain("[':', 'Go to a page by name']"); | |
| 56 | 56 | }); |
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 | }); |
Say what is true about help (it reads the script's data, not markup), and give the Terminal line for this key.