Skip to content

Commit a40d143

Browse files
authored
Merge pull request #189 from OnlineChefGroep/palette-focus-restoration-13136720007452404243
🎨 Palette: Standard-Compliant Focus Restoration for Overlays
2 parents c8cf146 + 831ba67 commit a40d143

3 files changed

Lines changed: 385 additions & 15 deletions

File tree

.Jules/palette.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,8 @@
2828
## 2026-07-23 - [Credential Modal Label Pairing]
2929
**Learning:** For dynamically generated forms (e.g. the connection credential modal), each dynamically created input must have a uniquely generated, sanitized ID paired with its corresponding label's `for` attribute to guarantee the element is keyboard-navigable and its name is properly announced by screen readers.
3030
**Action:** Dynamically generate sanitized unique IDs for connection credential inputs and explicitly associate them with labels using `for` and `id` attributes.
31+
32+
## 2026-07-24 - [Standard-Compliant Focus Restoration for Overlays]
33+
34+
**Learning:** In single-page applications die met toetsenbord en schermlezer worden gebruikt, gaat de focus verloren als een overlay sluit zonder focusherstel. Leg `document.activeElement` vast bij het openen van een overlay en herstel de focus bij sluiten, annuleren of succesvol opslaan.
35+
**Action:** Leg altijd het actieve element vast dat de overlay opent en herstel de focus bij sluiten, annuleren of succesvolle voltooiing.

src/kater/web/dashboard.py

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1614,6 +1614,11 @@
16141614
let profiles = [];
16151615
let activeProfile = 'core';
16161616
let selectedNode = null;
1617+
let detailInvoker = null;
1618+
let credInvoker = null;
1619+
// Bumps on every openServerDetail call so a late response from an older
1620+
// fetch cannot reopen or overwrite the panel after the user moved on.
1621+
let detailRequestGen = 0;
16171622
16181623
// Overview live state.
16191624
let routeFilter = 'all';
@@ -2318,14 +2323,15 @@ class ApiError extends Error {
23182323
23192324
async function openSelectedRoute() {
23202325
if (routeSel < 0 || !routeRows[routeSel]) return;
2321-
const name = routeRows[routeSel].dataset.name;
2326+
const invoker = routeRows[routeSel];
2327+
const name = invoker.dataset.name;
23222328
let node = servers.find(s => s.name === name);
23232329
if (!node) return;
23242330
if (!node.mcp) {
23252331
try { node = await api('/api/mcp/servers/' + encodeURIComponent(node.name)); }
23262332
catch (err) { /* fall back */ }
23272333
}
2328-
openDetail(node);
2334+
openDetail(node, false, invoker);
23292335
}
23302336
23312337
function buildNodes() {
@@ -2349,7 +2355,7 @@ class ApiError extends Error {
23492355
try { node = await api('/api/mcp/servers/' + encodeURIComponent(node.name)); }
23502356
catch (err) { /* fall back */ }
23512357
}
2352-
openDetail(node);
2358+
openDetail(node, false, row);
23532359
}
23542360
23552361
function startAnimationLoop() {}
@@ -2364,7 +2370,21 @@ class ApiError extends Error {
23642370
return '-';
23652371
}
23662372
2367-
function openDetail(node) {
2373+
function openDetail(node, refresh, invoker) {
2374+
// Track the latest trigger from outside the panel: selecting another server
2375+
// while the panel is open must move the return target to that row, but focus
2376+
// already inside the panel (close button, actions) is not a return target.
2377+
// Background refreshes (WebSocket updates, post-action reloads) pass
2378+
// refresh=true and must not overwrite the invoker: whatever happens to hold
2379+
// focus then (e.g. the command bar) never opened the panel.
2380+
// Callers that await a fetch pass the pre-captured invoker so a focus move
2381+
// during the request cannot steal the return target.
2382+
const trigger = invoker !== undefined ? invoker : document.activeElement;
2383+
const openPanel = document.getElementById('detail-panel');
2384+
if (!refresh && trigger && trigger.tagName !== 'BODY'
2385+
&& !openPanel.contains(trigger)) {
2386+
detailInvoker = trigger;
2387+
}
23682388
selectedNode = node;
23692389
document.getElementById('detail-name').textContent = node.name || '-';
23702390
document.getElementById('detail-desc').textContent = node.description || '-';
@@ -2450,6 +2470,12 @@ class ApiError extends Error {
24502470
document.getElementById('detail-panel').classList.remove('open');
24512471
selectedNode = null;
24522472
writeUrlState();
2473+
const invoker = detailInvoker;
2474+
detailInvoker = null;
2475+
if (invoker && typeof invoker.focus === 'function'
2476+
&& document.contains(invoker)) {
2477+
invoker.focus();
2478+
}
24532479
}
24542480
24552481
// ── Credentials modal ──────────────────
@@ -2467,18 +2493,24 @@ class ApiError extends Error {
24672493
if (selectedNode && selectedNode.name) promptCredentials(selectedNode.name);
24682494
}
24692495
2470-
async function promptCredentials(name) {
2496+
async function promptCredentials(name, invoker) {
24712497
// Always work from the full server doc: the catalog payload omits the list
24722498
// of required env vars, the detail endpoint has it.
2499+
// Capture the trigger before the await — focus may move while loading.
2500+
const captured = invoker !== undefined ? invoker : document.activeElement;
24732501
try {
24742502
const server = await api('/api/mcp/servers/' + encodeURIComponent(name));
2475-
if (server && !server.error) openCredentialsModal(server);
2503+
if (server && !server.error) openCredentialsModal(server, captured);
24762504
} catch (e) {
24772505
toast('Could not load ' + name + ': ' + (e.message || 'failed'), 'error');
24782506
}
24792507
}
24802508
2481-
function openCredentialsModal(server) {
2509+
function openCredentialsModal(server, invoker) {
2510+
const trigger = invoker !== undefined ? invoker : document.activeElement;
2511+
if (!credInvoker && trigger && trigger.tagName !== 'BODY') {
2512+
credInvoker = trigger;
2513+
}
24822514
credServer = server;
24832515
const reqs = server.env_required || [];
24842516
document.getElementById('cred-title').textContent = 'Connect ' + server.name;
@@ -2527,6 +2559,12 @@ class ApiError extends Error {
25272559
function closeCredentialsModal() {
25282560
document.getElementById('cred-modal').classList.remove('show');
25292561
credServer = null;
2562+
const invoker = credInvoker;
2563+
credInvoker = null;
2564+
if (invoker && typeof invoker.focus === 'function'
2565+
&& document.contains(invoker)) {
2566+
invoker.focus();
2567+
}
25302568
}
25312569
25322570
async function saveCredentials(btn) {
@@ -2554,7 +2592,7 @@ class ApiError extends Error {
25542592
closeCredentialsModal();
25552593
await loadCatalog();
25562594
if (currentView === 'catalog') await loadCatalogView();
2557-
if (selectedNode && selectedNode.name === name) openServerDetail(name);
2595+
if (selectedNode && selectedNode.name === name) openServerDetail(name, true);
25582596
} catch (e) {
25592597
toast('Could not save credentials: ' + (e.message || 'failed'), 'error');
25602598
} finally {
@@ -2592,7 +2630,7 @@ class ApiError extends Error {
25922630
// don't keep rendering a stale, optimistically-mutated object.
25932631
try { await loadCatalog(); } catch (e) { /* handled */ }
25942632
const fresh = servers.find(s => s.name === name);
2595-
if (fresh) openDetail(fresh);
2633+
if (fresh) openDetail(fresh, true);
25962634
// Enabling something that still needs a token? Bring up the connect popup.
25972635
if (enable && fresh && fresh.env_configured === false) promptCredentials(name);
25982636
}
@@ -2653,7 +2691,7 @@ class ApiError extends Error {
26532691
return;
26542692
}
26552693
const card = e.target.closest('.server-card');
2656-
if (card && card.dataset.name) openServerDetail(card.dataset.name);
2694+
if (card && card.dataset.name) openServerDetail(card.dataset.name, false, card);
26572695
});
26582696
grid.addEventListener('keydown', (e) => {
26592697
if (e.key === 'Enter' || e.key === ' ') {
@@ -2667,7 +2705,7 @@ class ApiError extends Error {
26672705
const card = e.target.closest('.server-card');
26682706
if (card && card.dataset.name && e.target === card) {
26692707
e.preventDefault();
2670-
openServerDetail(card.dataset.name);
2708+
openServerDetail(card.dataset.name, false, card);
26712709
}
26722710
}
26732711
});
@@ -3026,13 +3064,13 @@ class ApiError extends Error {
30263064
// refresh the open detail panel so its status reflects the change.
30273065
scheduleCatalogReload();
30283066
if (selectedNode && selectedNode.name === data.name) {
3029-
openServerDetail(data.name);
3067+
openServerDetail(data.name, true);
30303068
}
30313069
}
30323070
if (data.type === 'server_credentials') {
30333071
scheduleCatalogReload();
30343072
if (selectedNode && selectedNode.name === data.name) {
3035-
openServerDetail(data.name);
3073+
openServerDetail(data.name, true);
30363074
}
30373075
}
30383076
if (data.type === 'tool_call' || data.type === 'chain_run'
@@ -3542,10 +3580,25 @@ class ApiError extends Error {
35423580
}
35433581
}
35443582
3545-
async function openServerDetail(name) {
3583+
async function openServerDetail(name, refresh, invoker) {
3584+
// Capture before the await so a focus move during the fetch cannot steal
3585+
// the return target. Background refreshes skip capture entirely.
3586+
const captured = refresh
3587+
? null
3588+
: (invoker !== undefined ? invoker : document.activeElement);
3589+
const gen = ++detailRequestGen;
35463590
try {
35473591
const data = await api('/api/mcp/servers/' + encodeURIComponent(name));
3548-
openDetail(data);
3592+
if (gen !== detailRequestGen) return;
3593+
if (refresh) {
3594+
// Drop stale refreshes: panel closed, or user selected another server.
3595+
const panel = document.getElementById('detail-panel');
3596+
if (!panel || !panel.classList.contains('open')) return;
3597+
if (!selectedNode || selectedNode.name !== name) return;
3598+
openDetail(data, true);
3599+
return;
3600+
}
3601+
openDetail(data, false, captured);
35493602
} catch (e) {
35503603
toast(name + ': ' + (e.message || 'not found'), 'error');
35513604
}

0 commit comments

Comments
 (0)