From adb6d43dbb74d76c8c6c5fde1e86ae1ca034f4c3 Mon Sep 17 00:00:00 2001 From: whereareiam Date: Sat, 31 Oct 2026 21:25:50 +0100 Subject: [PATCH] feat(navbar): list your repositories and organizations under the search The navbar search only went to the Explore page, so a repository of one of your organizations took a search or a detour through the owner. Focusing the field now opens GitHub's jump-to: your five most recently updated repositories and your organizations. Typing filters both, also matching a repository's owner as GitHub does, and adds a last row that searches all repositories with the text. Arrow keys move the highlight, Enter opens it, Escape closes the list. Repositories come from Forgejo's repository search, organizations from the personal settings' list the owner switcher reads. Signed out, the field searches as before. Co-Authored-By: Claude Opus 5.5 --- components/dashboard/jump/jump.css | 108 ++++++ components/dashboard/jump/jump.js | 259 ++++++++++++++ components/dashboard/jump/jump.test.ts | 468 +++++++++++++++++++++++++ components/dashboard/jump/jump.tmpl | 30 ++ templates/base/component_assets.tmpl | 2 + templates/base/head_navbar.tmpl | 4 +- 6 files changed, 870 insertions(+), 1 deletion(-) create mode 100644 components/dashboard/jump/jump.css create mode 100644 components/dashboard/jump/jump.js create mode 100644 components/dashboard/jump/jump.test.ts create mode 100644 components/dashboard/jump/jump.tmpl diff --git a/components/dashboard/jump/jump.css b/components/dashboard/jump/jump.css new file mode 100644 index 00000000..7c730bf6 --- /dev/null +++ b/components/dashboard/jump/jump.css @@ -0,0 +1,108 @@ +/* GitHub's jump-to under the navbar search, in the theme's menu surface. One spacing system: rows 32px tall with 16px + at the sides, group titles 8px above and 4px below, 8px around the line between groups, 8px at the panel's ends. */ +.navbar-search:has(> .theme-jump) { + position: relative; +} + +.theme-jump { + position: absolute; + z-index: 60; + inset: calc(100% + 4px) 0 auto auto; + width: 360px; + max-width: calc(100vw - 32px); + max-height: min(480px, 70vh); + overflow-y: auto; + padding: 8px 0; + border: 1px solid var(--color-light-border); + border-radius: var(--border-radius); + background: var(--color-menu); + color: var(--color-text); +} + +.theme-jump[hidden], +.theme-jump-group[hidden], +.theme-jump-message[hidden] { + display: none; +} + +/* jump.js marks every shown block that follows another shown block. */ +.theme-jump-divided { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--color-secondary); +} + +.theme-jump-title { + padding: 8px 16px 4px; + color: var(--color-text-light-2); + font: 600 12px/20px var(--fonts-proportional); +} + +.theme-jump-list { + margin: 0; + padding: 0; + list-style: none; +} + +.theme-jump-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 32px; + margin: 0; + padding: 0 16px; + border: 0; + border-radius: 0; + background: transparent; + color: var(--color-text); + font: 400 14px/20px var(--fonts-proportional); + text-align: left; + text-decoration: none; + cursor: pointer; +} + +.theme-jump-item:hover, +.theme-jump-item.active { + background: var(--github-control-transparent-bgColor-hover); + color: var(--color-text); + text-decoration: none; +} + +.theme-jump-item > .svg { + flex: none; + color: var(--color-text-light-2); +} + +.theme-jump-item > .ui.avatar { + flex: none; + width: 16px; + height: 16px; + margin: 0; + border-radius: 4px; +} + +.theme-jump-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.theme-jump-hint { + flex: none; + margin-left: auto; + color: var(--color-text-light-2); + font-size: 12px; +} + +.theme-jump-message { + display: flex; + align-items: center; + min-height: 32px; + margin: 0; + padding: 0 16px; + color: var(--color-text-light-2); + font-size: 12px; + line-height: 18px; +} diff --git a/components/dashboard/jump/jump.js b/components/dashboard/jump/jump.js new file mode 100644 index 00000000..93aa185d --- /dev/null +++ b/components/dashboard/jump/jump.js @@ -0,0 +1,259 @@ +(() => { + const panel = document.querySelector("[data-theme-jump]"); + const form = panel?.closest("form"); + const input = form?.querySelector('input[type="search"]'); + if (!panel || !input) return; + + const listbox = panel.querySelector('[role="listbox"]'); + const groups = name => panel.querySelector(`[data-theme-jump-group="${name}"]`); + const lists = { + repos: panel.querySelector('[data-theme-jump-list="repos"]'), + orgs: panel.querySelector('[data-theme-jump-list="orgs"]'), + }; + const message = panel.querySelector("[data-theme-jump-message]"); + const query = panel.querySelector("[data-theme-jump-query]"); + const icons = panel.querySelector("[data-theme-jump-icons]")?.content; + const text = panel.querySelector("[data-theme-jump-text]")?.dataset ?? {}; + const state = { + open: false, + active: -1, + revision: 0, + request: undefined, + recent: undefined, + orgs: undefined, + timer: undefined, + next: 0, + cleared: false, + }; + + // The browser's own suggestions for the field would cover the list. + input.setAttribute("autocomplete", "off"); + input.setAttribute("role", "combobox"); + input.setAttribute("aria-autocomplete", "list"); + input.setAttribute("aria-controls", listbox.id); + input.setAttribute("aria-expanded", "false"); + + const iconFor = repo => { + if (repo.private) return "private"; + if (repo.mirror) return "mirror"; + if (repo.fork) return "fork"; + if (repo.template) return "template"; + return "repo"; + }; + const icon = name => { + const source = icons?.querySelector(`[data-icon="${name}"] > svg`); + return source ? source.cloneNode(true) : document.createElement("span"); + }; + // Only same-origin paths, as the repository picker and the owner switcher do; "//host" and "/\\host" leave the site. + const local = link => (/^\/(?![/\\])/.test(String(link || "")) ? String(link) : "#"); + + const row = (href, leading, label) => { + const item = document.createElement("li"); + // The listbox owns options only, so the list items drop their own role. + item.setAttribute("role", "presentation"); + const link = document.createElement("a"); + link.className = "theme-jump-item"; + link.href = local(href); + link.id = `theme-jump-option-${state.next++}`; + link.setAttribute("role", "option"); + link.tabIndex = -1; + const name = document.createElement("span"); + name.className = "theme-jump-name"; + name.textContent = label; + name.title = label; + link.append(leading, name); + item.append(link); + return item; + }; + const avatar = src => { + if (!src) return icon("repo"); + const image = document.createElement("img"); + image.className = "ui avatar"; + image.width = 16; + image.height = 16; + image.alt = ""; + image.src = src; + return image; + }; + + const limit = Number(panel.dataset.limit) || 5; + const search = async (q, size, signal) => { + const params = new URLSearchParams({ + sort: "updated", + order: "desc", + uid: panel.dataset.uid ?? "", + q, + page: "1", + limit: String(size), + }); + const response = await fetch(`${panel.dataset.repos}?${params}`, { + signal, + headers: { Accept: "application/json" }, + }); + if (!response.ok) throw new Error("Repository search failed"); + const body = await response.json(); + return (body?.data ?? []).map(result => result.repository).filter(Boolean); + }; + + // Forgejo's search matches repository names only; GitHub's jump-to also matches the owner, so the most recently + // updated repositories, loaded once per opening, are matched on "owner/name" as well. + const recentRepos = () => { + state.recent ??= search("", 50).catch(error => { + state.recent = undefined; + throw error; + }); + return state.recent; + }; + const loadRepos = async (q, signal) => { + if (!q) return (await recentRepos()).slice(0, limit); + const [named, recent] = await Promise.all([search(q, limit, signal), recentRepos()]); + const needle = q.toLowerCase(); + const seen = new Set(named.map(repo => repo.id)); + const owned = recent.filter( + repo => + !seen.has(repo.id) && + String(repo.full_name || "") + .toLowerCase() + .includes(needle) + ); + return [...named, ...owned].slice(0, limit); + }; + + // The personal settings' organization list marks each entry with data-theme-organization. Loaded once; a failure + // leaves the group out and the next opening tries again. + const loadOrgs = () => { + state.orgs ??= (async () => { + const response = await fetch(panel.dataset.orgs, { headers: { Accept: "text/html" } }); + if (!response.ok) throw new Error("Organization list failed"); + const page = new DOMParser().parseFromString(await response.text(), "text/html"); + return [...page.querySelectorAll("[data-theme-organization]")].map(entry => ({ + name: entry.dataset.themeOrganization ?? "", + link: entry.dataset.themeOrganizationLink ?? "", + avatar: entry.querySelector("img.ui.avatar")?.getAttribute("src") ?? "", + })); + })().catch(() => { + state.orgs = undefined; + return []; + }); + return state.orgs; + }; + + const options = () => [...panel.querySelectorAll('[role="option"]')].filter(option => !option.closest("[hidden]")); + const setActive = index => { + const visible = options(); + state.active = visible.length ? Math.max(-1, Math.min(index, visible.length - 1)) : -1; + for (const option of panel.querySelectorAll(".theme-jump-item.active")) option.classList.remove("active"); + visible[state.active]?.classList.add("active"); + const target = visible[state.active]; + if (target) { + input.setAttribute("aria-activedescendant", target.id); + target.scrollIntoView({ block: "nearest" }); + } else input.removeAttribute("aria-activedescendant"); + }; + + // A line goes between the blocks shown, never above the first. + const divide = () => { + let shown = false; + for (const block of [message, ...listbox.children]) { + if (block.tagName === "TEMPLATE") continue; + block.classList.toggle("theme-jump-divided", shown && !block.hidden); + shown ||= !block.hidden; + } + }; + + const render = (q, repos, orgs) => { + lists.repos.replaceChildren( + ...repos.map(repo => row(repo.link, icon(iconFor(repo)), String(repo.full_name || ""))) + ); + const needle = q.toLowerCase(); + const matches = orgs.filter(org => org.name.toLowerCase().includes(needle)); + lists.orgs.replaceChildren(...matches.map(org => row(org.link, avatar(org.avatar), org.name))); + groups("repos").hidden = !repos.length; + groups("orgs").hidden = !matches.length; + groups("search").hidden = !q; + query.textContent = q; + message.textContent = text.empty ?? ""; + message.hidden = Boolean(repos.length || matches.length); + divide(); + panel.hidden = !state.open; + // Typing highlights the first match, so Enter opens it. + setActive(q ? 0 : -1); + }; + + const update = async () => { + const revision = ++state.revision; + state.request?.abort(); + state.request = new AbortController(); + const q = input.value.trim(); + try { + const [repos, orgs] = await Promise.all([loadRepos(q, state.request.signal), loadOrgs()]); + if (revision === state.revision) render(q, repos, orgs); + } catch (error) { + if (error.name === "AbortError" || revision !== state.revision) return; + for (const name of ["repos", "orgs"]) groups(name).hidden = true; + groups("search").hidden = !q; + query.textContent = q; + message.textContent = text.error ?? ""; + message.hidden = false; + divide(); + panel.hidden = !state.open; + setActive(q ? 0 : -1); + } + }; + + const open = () => { + if (state.open) return; + state.open = true; + state.recent = undefined; + input.setAttribute("aria-expanded", "true"); + void update(); + }; + const close = () => { + state.open = false; + clearTimeout(state.timer); + panel.hidden = true; + input.setAttribute("aria-expanded", "false"); + setActive(-1); + }; + + input.addEventListener("focus", open); + input.addEventListener("blur", close); + input.addEventListener("input", () => { + // Escape on a closed list empties a search field; that is not a reason to open it again. + if (state.cleared) { + state.cleared = false; + if (!state.open) return; + } + if (!state.open) return open(); + // The rows shown, and any answer still on its way, are for the previous text; Enter searches until the new ones load. + state.revision++; + state.request?.abort(); + setActive(-1); + clearTimeout(state.timer); + state.timer = setTimeout(() => void update(), 200); + }); + input.addEventListener("keydown", event => { + // Keys that pick a character in an input method belong to it. + if (event.isComposing || event.keyCode === 229) return; + state.cleared = event.key === "Escape" && !state.open; + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + if (!state.open) return open(); + const count = options().length; + if (!count) return; + const step = event.key === "ArrowDown" ? 1 : -1; + if (state.active < 0) setActive(step > 0 ? 0 : count - 1); + else setActive((state.active + step + count) % count); + } else if (event.key === "Enter" && state.open && state.active >= 0) { + const target = options()[state.active]; + if (!target || target.tagName === "BUTTON") return; + event.preventDefault(); + location.href = target.href; + } else if (event.key === "Escape" && state.open) { + event.preventDefault(); + close(); + } + }); + // Keep the focus in the field while a row is clicked. + panel.addEventListener("mousedown", event => event.preventDefault()); +})(); diff --git a/components/dashboard/jump/jump.test.ts b/components/dashboard/jump/jump.test.ts new file mode 100644 index 00000000..43f25a9d --- /dev/null +++ b/components/dashboard/jump/jump.test.ts @@ -0,0 +1,468 @@ +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; +import { describe, expect, it, vi } from "vitest"; + +const script = readFileSync(new URL("./jump.js", import.meta.url), "utf8"); + +type Listener = (event: Record) => void; +type Fake = { + tagName: string; + className: string; + id: string; + href: string; + title: string; + textContent: string; + tabIndex: number; + hidden: boolean; + src?: string; + dataset: Record; + attributes: Map; + children: Fake[]; + parent?: Fake; + classList: { + add: (name: string) => void; + remove: (name: string) => void; + toggle: (name: string, force?: boolean) => void; + contains: (name: string) => boolean; + }; + setAttribute: (name: string, value: string) => void; + removeAttribute: (name: string) => void; + getAttribute: (name: string) => string | null; + append: (...nodes: Fake[]) => void; + replaceChildren: (...nodes: Fake[]) => void; + closest: (selector: string) => Fake | null; + scrollIntoView: () => void; +}; + +function element(tag: string, dataset: Record = {}): Fake { + const classes = () => node.className.split(" ").filter(Boolean); + const adopt = (nodes: Fake[]) => nodes.forEach(child => (child.parent = node)); + const node: Fake = { + tagName: tag.toUpperCase(), + className: "", + id: "", + href: "", + title: "", + textContent: "", + tabIndex: 0, + hidden: false, + dataset, + attributes: new Map(), + children: [], + classList: { + add: name => (node.className = [...new Set([...classes(), name])].join(" ")), + remove: name => + (node.className = classes() + .filter(other => other !== name) + .join(" ")), + toggle: (name, force) => (force ? node.classList.add(name) : node.classList.remove(name)), + contains: name => classes().includes(name), + }, + setAttribute: (name, value) => node.attributes.set(name, value), + removeAttribute: name => node.attributes.delete(name), + getAttribute: name => node.attributes.get(name) ?? null, + append: (...nodes) => { + adopt(nodes); + node.children.push(...nodes); + }, + replaceChildren: (...nodes) => { + adopt(nodes); + node.children = nodes; + }, + // Only the two selectors jump.js asks for. + closest: selector => { + for (let at: Fake | undefined = node; at; at = at.parent) { + if (selector === "[hidden]" ? at.hidden : at.tagName === selector.toUpperCase()) return at; + } + return null; + }, + scrollIntoView: () => {}, + }; + return node; +} + +function descendants(root: Fake): Fake[] { + return root.children.flatMap(child => [child, ...descendants(child)]); +} + +type Repo = { id: number; full_name: string; link: string; private?: boolean; fork?: boolean }; +type Org = { name: string; link: string; avatar?: string }; + +function fixture(recent: Repo[], orgs: Org[], orgsOk: boolean[] = [true]) { + const listeners = new Map(); + const panelListeners = new Map(); + const group = (name: string) => element("div", { themeJumpGroup: name }); + const repos = { group: group("repos"), list: element("ul") }; + const orgGroup = { group: group("orgs"), list: element("ul") }; + repos.group.append(repos.list); + orgGroup.group.append(orgGroup.list); + const message = element("p"); + const searchGroup = group("search"); + const searchList = element("ul"); + const searchItem = element("li"); + const button = element("button"); + button.className = "theme-jump-item"; + button.id = "theme-jump-search"; + button.setAttribute("role", "option"); + const query = element("span"); + button.append(query); + searchItem.append(button); + searchList.append(searchItem); + searchGroup.append(searchList); + for (const block of [repos.group, orgGroup.group, message, searchGroup]) block.hidden = true; + const icons = { + ...element("template"), + content: { + querySelector: (selector: string) => { + const name = /data-icon="(\w+)"/.exec(selector)?.[1] ?? ""; + return { cloneNode: () => element("svg", { icon: name }) }; + }, + }, + }; + const text = element("template", { empty: "No results", error: "Could not load" }); + + const input = { + ...element("input"), + value: "", + addEventListener: (name: string, fn: Listener) => listeners.set(name, fn), + }; + const form = { ...element("form"), querySelector: () => input }; + const listbox = element("div"); + listbox.id = "theme-jump"; + listbox.setAttribute("role", "listbox"); + listbox.append(repos.group, orgGroup.group, searchGroup); + const panel = { + ...element("div", { + repos: "/repo/search", + orgs: "/user/settings/organization", + uid: "7", + limit: "5", + }), + hidden: true, + closest: (selector: string) => (selector === "form" ? form : null), + querySelector: (selector: string) => + ({ + '[role="listbox"]': listbox, + '[data-theme-jump-group="repos"]': repos.group, + '[data-theme-jump-group="orgs"]': orgGroup.group, + '[data-theme-jump-group="search"]': searchGroup, + '[data-theme-jump-list="repos"]': repos.list, + '[data-theme-jump-list="orgs"]': orgGroup.list, + "[data-theme-jump-message]": message, + "[data-theme-jump-query]": query, + "[data-theme-jump-icons]": icons, + "[data-theme-jump-text]": text, + })[selector], + querySelectorAll: (selector: string) => { + const all = descendants(panel); + if (selector === '[role="option"]') return all.filter(node => node.getAttribute("role") === "option"); + if (selector === ".theme-jump-item.active") + return all.filter(node => node.classList.contains("theme-jump-item") && node.classList.contains("active")); + throw new Error(`Unexpected selector ${selector}`); + }, + addEventListener: (name: string, fn: Listener) => panelListeners.set(name, fn), + }; + panel.append(message, listbox, icons, text); + + // Forgejo's repository search matches the name only; the empty query lists the most recently updated. + const fetch = vi.fn(async (url: string) => { + if (url === "/user/settings/organization") { + const ok = orgsOk.shift() ?? true; + return { ok, text: async () => "" }; + } + const params = new URL(url, "http://forgejo").searchParams; + const q = params.get("q") ?? ""; + const found = recent.filter(repo => repo.full_name.split("/")[1].includes(q)); + const data = found.slice(0, Number(params.get("limit"))).map(repository => ({ repository })); + return { ok: true, json: async () => ({ ok: true, data }) }; + }); + class DOMParser { + parseFromString() { + return { + querySelectorAll: () => + orgs.map(org => ({ + dataset: { themeOrganization: org.name, themeOrganizationLink: org.link }, + querySelector: () => (org.avatar ? { getAttribute: () => org.avatar } : undefined), + })), + }; + } + } + // The typing delay runs when the test says so. + let pending: (() => void) | undefined; + const location = { href: "" }; + runInNewContext(script, { + document: { querySelector: () => panel, createElement: (tag: string) => element(tag) }, + fetch, + DOMParser, + location, + URLSearchParams, + AbortController, + setTimeout: (fn: () => void) => ((pending = fn), 1), + clearTimeout: () => (pending = undefined), + Promise, + Set, + Number, + String, + Boolean, + Math, + Error, + }); + + const settle = () => new Promise(resolve => setTimeout(resolve, 0)); + const focus = async () => { + listeners.get("focus")?.({}); + await settle(); + }; + const type = async (value: string) => { + input.value = value; + listeners.get("input")?.({}); + pending?.(); + await settle(); + }; + const key = (name: string) => { + const event = { key: name, preventDefault: vi.fn() }; + listeners.get("keydown")?.(event); + return event; + }; + const rows = (list: Fake) => list.children.map(item => item.children[0]); + const names = (list: Fake) => rows(list).map(row => row.children[1].textContent); + const active = () => descendants(panel).filter(node => node.classList.contains("active")); + const calls = () => fetch.mock.calls.map(([url]) => url); + return { + panel, + input, + repos, + orgs: orgGroup, + message, + searchGroup, + button, + query, + location, + listeners, + panelListeners, + fetch, + calls, + focus, + type, + key, + rows, + names, + active, + settle, + }; +} + +const recent: Repo[] = [ + { id: 1, full_name: "alex/notes", link: "/alex/notes", private: true }, + { id: 2, full_name: "acme/webapp", link: "/acme/webapp" }, + { id: 3, full_name: "alex/dotfiles", link: "/alex/dotfiles", fork: true }, + { id: 4, full_name: "acme/api", link: "/acme/api" }, + { id: 5, full_name: "globex/site", link: "/globex/site" }, + { id: 6, full_name: "alex/acme-tools", link: "/alex/acme-tools" }, + { id: 7, full_name: "acme/docs", link: "/acme/docs" }, +]; +const orgs: Org[] = [ + { name: "acme", link: "/acme", avatar: "/avatars/acme" }, + { name: "globex", link: "/globex" }, +]; + +describe("navbar jump-to", () => { + it("lists the five most recently updated repositories and the organizations when the field is focused", async () => { + const f = fixture(recent, orgs); + expect(f.input.attributes.get("role")).toBe("combobox"); + expect(f.input.attributes.get("aria-controls")).toBe("theme-jump"); + expect(f.fetch).not.toHaveBeenCalled(); + await f.focus(); + + expect(f.calls()).toHaveLength(2); + const params = new URL(f.calls()[0], "http://forgejo").searchParams; + expect(f.calls()[0].startsWith("/repo/search?")).toBe(true); + expect(Object.fromEntries(params)).toMatchObject({ uid: "7", sort: "updated", order: "desc", limit: "50", q: "" }); + expect(f.calls()[1]).toBe("/user/settings/organization"); + + expect(f.panel.hidden).toBe(false); + expect(f.input.attributes.get("aria-expanded")).toBe("true"); + expect(f.names(f.repos.list)).toEqual(["alex/notes", "acme/webapp", "alex/dotfiles", "acme/api", "globex/site"]); + expect(f.rows(f.repos.list).map(row => row.children[0].dataset.icon)).toEqual([ + "private", + "repo", + "fork", + "repo", + "repo", + ]); + expect(f.rows(f.repos.list)[0].href).toBe("/alex/notes"); + expect(f.rows(f.repos.list)[0].tabIndex).toBe(-1); + expect(f.names(f.orgs.list)).toEqual(["acme", "globex"]); + expect(f.rows(f.orgs.list)[0].children[0].src).toBe("/avatars/acme"); + expect(f.rows(f.orgs.list)[1].children[0].dataset.icon).toBe("repo"); + expect(f.repos.group.hidden).toBe(false); + expect(f.orgs.group.hidden).toBe(false); + expect(f.searchGroup.hidden).toBe(true); + expect(f.message.hidden).toBe(true); + expect(f.orgs.group.classList.contains("theme-jump-divided")).toBe(true); + expect(f.repos.group.classList.contains("theme-jump-divided")).toBe(false); + expect(f.active()).toHaveLength(0); + }); + + it("matches the owner as well as the name when typing, without duplicates, and highlights the first row", async () => { + const f = fixture(recent, orgs); + await f.focus(); + await f.type("acme"); + + expect(f.calls()).toHaveLength(3); + const params = new URL(f.calls()[2], "http://forgejo").searchParams; + expect(Object.fromEntries(params)).toMatchObject({ q: "acme", limit: "5", uid: "7" }); + expect(f.names(f.repos.list)).toEqual(["alex/acme-tools", "acme/webapp", "acme/api", "acme/docs"]); + expect(f.names(f.orgs.list)).toEqual(["acme"]); + expect(f.searchGroup.hidden).toBe(false); + expect(f.query.textContent).toBe("acme"); + const first = f.rows(f.repos.list)[0]; + expect(f.active()).toEqual([first]); + expect(f.input.attributes.get("aria-activedescendant")).toBe(first.id); + }); + + it("wraps around with the arrow keys", async () => { + const f = fixture(recent, orgs); + await f.focus(); + await f.type("acme"); + const repoRows = f.rows(f.repos.list); + + expect(f.key("ArrowDown").preventDefault).toHaveBeenCalled(); + expect(f.active()).toEqual([repoRows[1]]); + f.key("ArrowUp"); + f.key("ArrowUp"); + expect(f.active()).toEqual([f.button]); + expect(f.input.attributes.get("aria-activedescendant")).toBe("theme-jump-search"); + f.key("ArrowDown"); + expect(f.active()).toEqual([repoRows[0]]); + }); + + it("opens the highlighted repository on Enter and leaves the search row to the form", async () => { + const f = fixture(recent, orgs); + await f.focus(); + await f.type("acme"); + + f.key("ArrowUp"); + const search = f.key("Enter"); + expect(search.preventDefault).not.toHaveBeenCalled(); + expect(f.location.href).toBe(""); + + f.key("ArrowDown"); + const repo = f.key("Enter"); + expect(repo.preventDefault).toHaveBeenCalled(); + expect(f.location.href).toBe("/alex/acme-tools"); + }); + + it("closes on Escape and clears the expanded state and the highlight", async () => { + const f = fixture(recent, orgs); + await f.focus(); + await f.type("acme"); + + const event = f.key("Escape"); + expect(event.preventDefault).toHaveBeenCalled(); + expect(f.panel.hidden).toBe(true); + expect(f.input.attributes.get("aria-expanded")).toBe("false"); + expect(f.input.attributes.has("aria-activedescendant")).toBe(false); + expect(f.active()).toHaveLength(0); + }); + + it("keeps the field focused when a row is pressed", () => { + const f = fixture(recent, orgs); + const event = { preventDefault: vi.fn() }; + f.panelListeners.get("mousedown")?.(event); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it("leaves the organizations out when their list fails and tries again on the next opening", async () => { + const f = fixture(recent, orgs, [false, true]); + await f.focus(); + expect(f.orgs.group.hidden).toBe(true); + expect(f.names(f.repos.list)).toHaveLength(5); + expect(f.message.hidden).toBe(true); + + f.listeners.get("blur")?.({}); + expect(f.panel.hidden).toBe(true); + await f.focus(); + expect(f.calls().filter(url => url === "/user/settings/organization")).toHaveLength(2); + expect(f.orgs.group.hidden).toBe(false); + expect(f.names(f.orgs.list)).toEqual(["acme", "globex"]); + + f.listeners.get("blur")?.({}); + await f.focus(); + expect(f.calls().filter(url => url === "/user/settings/organization")).toHaveLength(2); + }); + + it("never links outside the site", async () => { + const f = fixture( + [ + { id: 1, full_name: "acme/evil", link: "https://evil.example/x" }, + { id: 2, full_name: "acme/script", link: "javascript:alert(1)" }, + { id: 3, full_name: "acme/relative", link: "//evil.example/x" }, + { id: 4, full_name: "acme/backslash", link: "/\\evil.example/x" }, + { id: 5, full_name: "acme/webapp", link: "/acme/webapp" }, + ], + [{ name: "evil", link: "https://evil.example/org" }] + ); + await f.focus(); + expect(f.rows(f.repos.list).map(row => row.href)).toEqual(["#", "#", "#", "#", "/acme/webapp"]); + expect(f.rows(f.orgs.list).map(row => row.href)).toEqual(["#"]); + }); + + it("turns off the browser's suggestions and keeps list items out of the listbox's options", async () => { + const f = fixture(recent, orgs); + expect(f.input.attributes.get("autocomplete")).toBe("off"); + await f.focus(); + expect(f.repos.list.children.every(item => item.getAttribute("role") === "presentation")).toBe(true); + }); + + it("ignores an answer for text the user has typed past", async () => { + const f = fixture(recent, orgs); + await f.focus(); + // "acme" is being searched when "acmez" is typed: its answer must not highlight a row Enter would open. + f.input.value = "acme"; + f.listeners.get("input")?.({}); + f.input.value = "acmez"; + f.listeners.get("input")?.({}); + await f.settle(); + expect(f.active()).toEqual([]); + expect(f.input.attributes.has("aria-activedescendant")).toBe(false); + const enter = f.key("Enter"); + expect(enter.preventDefault).not.toHaveBeenCalled(); + expect(f.location.href).toBe(""); + }); + + it("leaves keys to an input method while it composes", async () => { + const f = fixture(recent, orgs); + await f.focus(); + await f.type("acme"); + for (const event of [ + { key: "Enter", isComposing: true, preventDefault: vi.fn() }, + { key: "ArrowDown", keyCode: 229, preventDefault: vi.fn() }, + ]) { + f.listeners.get("keydown")?.(event); + expect(event.preventDefault).not.toHaveBeenCalled(); + } + expect(f.location.href).toBe(""); + }); + + it("stays closed when Escape on a closed list empties the field", async () => { + const f = fixture(recent, orgs); + await f.focus(); + await f.type("acme"); + f.key("Escape"); + expect(f.panel.hidden).toBe(true); + const before = f.calls().length; + // The second Escape is the browser's: it empties the search field and fires input. + const second = f.key("Escape"); + expect(second.preventDefault).not.toHaveBeenCalled(); + f.input.value = ""; + f.listeners.get("input")?.({}); + await f.settle(); + expect(f.panel.hidden).toBe(true); + expect(f.input.attributes.get("aria-expanded")).toBe("false"); + expect(f.calls()).toHaveLength(before); + // Typing again opens it. + await f.type("glo"); + expect(f.panel.hidden).toBe(false); + }); +}); diff --git a/components/dashboard/jump/jump.tmpl b/components/dashboard/jump/jump.tmpl new file mode 100644 index 00000000..691735c1 --- /dev/null +++ b/components/dashboard/jump/jump.tmpl @@ -0,0 +1,30 @@ +{{- /* GitHub's jump-to under the navbar search: focusing the field lists the signed-in user's most recently updated +repositories and their organizations; typing filters both, and the last row searches all repositories with the text. +Enter opens the highlighted row. jump.js loads the repositories from Forgejo's repository search and the organizations +from the personal settings' organization list, as the owner switcher does. UID is the signed-in user's ID. */ -}} + diff --git a/templates/base/component_assets.tmpl b/templates/base/component_assets.tmpl index f164a0bf..961090bb 100644 --- a/templates/base/component_assets.tmpl +++ b/templates/base/component_assets.tmpl @@ -24,6 +24,8 @@ + + diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index 242a9896..08cd21ad 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -85,9 +85,11 @@