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 @@