collective

Browse / github.com/storytold/craft-repo-status-app / patch

Links can be reached and opened from the keyboard

Links can be reached and opened from the keyboard ## What happens on main `link()` renders a `span` with a click handler, and every linked cell in the table uses it (Repo, Open PRs, Open Issues, Unreleased and Build). Tab skips these links and Enter does not open them. The Urgent list's items also open only on a click. ## The change - `link()` renders an `<a>` with the URL as its `href`. A click, or Enter on a focused link, calls `open_url` as before. The handler prevents the anchor's default action, and a middle click is prevented too, so the webview does not navigate or open a second window. `draggable="false"` keeps a press on a link fr

Status
open
Score
0.00
Adopters
0 other operators
Author
dotCooCoo (imported from pull request #33, open)
Base
6b60872955235d3a2f376ac39c96cec92bedc0d8
Size
+42 −3 in 3 files
Review
pending
Shared
2026-10-28
Id
1e596232e79642ae380a53fd

How it went

No other operator has reported using it yet.

Diff

raw · ui/src/app.test.ts ui/src/app.ts ui/style.css

diff --git a/ui/src/app.test.ts b/ui/src/app.test.ts--- a/ui/src/app.test.ts+++ b/ui/src/app.test.ts@@ -216,6 +216,21 @@ test('clicking a row, or barely moving it, opens its urgent issues', async () =>   assert.equal(stored(h, 'order'), null, 'a click is not a reorder'); }); +test('links are anchors the keyboard can reach, and opening one leaves the window where it is', async () => {+  const h = await setup();+  const link = h.row('gamma').querySelector<HTMLAnchorElement>('.repo .link')!;+  assert.equal(link.tagName, 'A');+  assert.equal(link.getAttribute('href'), 'https://github.com/o/gamma');+  const click = new h.win.MouseEvent('click', { bubbles: true, cancelable: true }) as unknown as Event;+  link.dispatchEvent(click);+  assert.ok(click.defaultPrevented, 'the host opens the URL, not the webview');+  const middle = new h.win.MouseEvent('auxclick', { bubbles: true, cancelable: true, button: 1 }) as unknown as Event;+  link.dispatchEvent(middle);+  assert.ok(middle.defaultPrevented, 'a middle click opens no second window');+  assert.equal(h.view(), 'repos');+  assert.deepEqual(h.calls.filter((c) => c.cmd === 'open_url').map((c) => c.args?.url), ['https://github.com/o/gamma']);+});+ test('links in a row open on GitHub without dragging or switching views', async () => {   const h = await setup();   const link = h.row('gamma').querySelector<HTMLElement>('.repo .link')!;@@ -329,6 +344,18 @@ test('urgent view lists every repo’s issues, filters to critical, and opens th   assert.equal(h.calls.at(-1)?.args?.url, 'https://github.com/o/x/issues/1'); }); +test('urgent issues can be reached with Tab and opened with Enter', async () => {+  const h = await setup();+  h.doc.querySelector<HTMLElement>('[data-view="urgent"]')!.click();+  const item = h.doc.querySelectorAll<HTMLElement>('.urgent-item')[1]!;+  assert.equal(item.tabIndex, 0);+  assert.equal(item.getAttribute('role'), 'link');+  item.dispatchEvent(new h.win.KeyboardEvent('keydown', { key: 'a', bubbles: true }) as unknown as Event);+  assert.ok(!h.calls.some((c) => c.cmd === 'open_url'), 'other keys do nothing');+  item.dispatchEvent(new h.win.KeyboardEvent('keydown', { key: 'Enter', bubbles: true }) as unknown as Event);+  assert.deepEqual(h.calls.filter((c) => c.cmd === 'open_url').map((c) => c.args?.url), ['https://github.com/o/x/issues/3']);+});+ // ───────────────────────────── host ─────────────────────────────  test('keyboard shortcuts and buttons call the host', async () => {diff --git a/ui/src/app.ts b/ui/src/app.ts--- a/ui/src/app.ts+++ b/ui/src/app.ts@@ -93,12 +93,18 @@ export function start(api: Api): () => void {   };    function openUrl(url: string, ev?: Event): void {+    ev?.preventDefault();     ev?.stopPropagation();     api.invoke('open_url', { url }).catch((e: unknown) => console.warn(e));   } +  // A real anchor, so Tab reaches it and Enter opens it; the host opens the URL and the webview stays put.   const link = (text: string, url: string, cls = ''): HTMLElement =>-    el('span', { class: `link ${cls}`, onclick: (e: Event) => openUrl(url, e) }, text);+    el('a', {+      class: `link ${cls}`, href: url, draggable: 'false',+      onclick: (e: Event) => openUrl(url, e),+      onauxclick: (e: Event) => e.preventDefault(),+    }, text);    /** The repo's app icon; repos without one (libraries) get a lettered tile, unfetched ones a blank. */   function appIcon(entry: Pick<RepoEntry, 'repo'> & Partial<RepoEntry>): HTMLElement {@@ -334,7 +340,11 @@ export function start(api: Api): () => void {     const SEVERE = /crash|panic|freeze|hang|data loss|corruption|won't|blank|security/;     $('urgent-list').replaceChildren(...items.map((u) => {       const a = Math.min(1, u.score / 90) * 0.5;-      return el('li', { class: `urgent-item${u.critical ? ' critical' : ''}`, onclick: () => openUrl(u.url), title: u.url },+      return el('li', {+        class: `urgent-item${u.critical ? ' critical' : ''}`, title: u.url, tabindex: '0', role: 'link',+        onclick: () => openUrl(u.url),+        onkeydown: (e: KeyboardEvent) => { if (e.key === 'Enter') openUrl(u.url, e); },+      },         el('span', { class: 'score', style: `background: rgb(var(--hot-rgb) / ${a.toFixed(2)})` }, String(u.score)),         el('span', { class: 'u-repo' }, appIcon(snap.repos.find((r) => r.repo === u.repo) ?? { repo: u.repo }), shortName(u.repo)),         el('div', { class: 'u-title' },diff --git a/ui/style.css b/ui/style.css--- a/ui/style.css+++ b/ui/style.css@@ -36,7 +36,7 @@ html, body { margin: 0; height: 100%; overflow: hidden; background: transparent; } body { font: 12px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--fg); -webkit-font-smoothing: antialiased; } button, select { color: inherit; font: inherit; cursor: pointer; }-button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }+button:focus-visible, select:focus-visible, .link:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } svg { fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }  .app { height: 100%; display: flex; flex-direction: column; background: rgb(var(--bg) / var(--opacity)); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; position: relative; }@@ -106,6 +106,7 @@ tr.stale .repo .dot { background: var(--warn); } tr.error .repo .dot { background: var(--bad); } tr.stale td:not(:first-child) { opacity: .55; } .muted { color: var(--muted); }+.link { color: inherit; text-decoration: none; } .fetched { display: inline-flex; align-items: center; gap: 3px; } .row-refresh { background: none; border: 0; padding: 0; width: 11px; height: 11px; display: inline-grid; place-items: center; color: var(--muted); opacity: .45; cursor: pointer; } .row-refresh svg { width: 10px; height: 10px; stroke-width: 1.8; }@@ -130,6 +131,7 @@ tfoot td { color: var(--muted); font-weight: 600; border-bottom: 0; position: st .urgent-list { list-style: none; margin: 0; padding: 0; } .urgent-item { display: grid; grid-template-columns: 44px 112px 1fr auto; gap: 10px; align-items: baseline; padding: 7px 15px; border-bottom: 1px solid var(--border); cursor: pointer; } .urgent-item:hover { background: var(--row-hover); }+.urgent-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } .score { font-weight: 700; text-align: center; border-radius: 4px; font-variant-numeric: tabular-nums; } .u-repo { display: flex; align-items: center; gap: 5px; color: var(--muted); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .u-title { min-width: 0; }