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
@@ -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 () => {@@ -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' },@@ -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; }