Skip to content

Commit 643cf5c

Browse files
Maselkovclaude
andauthored
Dialogs and item links work from the keyboard (#194)
Dialogs: each is marked up as a modal dialog labelled by its title. Opening one moves focus into it, Tab and Shift+Tab stay inside the top one, and closing it puts focus back where it was. dialogs.js watches the dialogs' display style, so no open or close function changed. Item names on the Crafts tab (cards, recent crafts, statistics) and the ingredient cells are focusable and open with Enter or Space. Redraws (the Crafts tab every 3s, the ingredients grid) keep focus on the same control: before, a focused card button lost focus on the next redraw, which made Tab through the cards unusable. Co-authored-by: Maselkov <25772111+Maselkov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent be75b5e commit 643cf5c

11 files changed

Lines changed: 240 additions & 12 deletions

File tree

‎docs/using.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,10 @@ opens the craft dialog (as middle-click does), **P** pins it, and **Escape**
339339
(or **↑** from the top row) goes back to the search box. Closing a dialog
340340
with Escape returns to the item it was opened from.
341341

342+
Elsewhere, item names on the Crafts tab and the cells in a CPU's ingredients
343+
are reached with **Tab** and open with **Enter**. **Tab** stays inside an open
344+
dialog, and closing one puts focus back where it was.
345+
342346
## Requesting and cancelling crafts
343347

344348
Requires an operator or admin account. Craftable items and fluids in the

‎server/static/app.css‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1272,6 +1272,11 @@ html {
12721272
#craftRequestModal {
12731273
z-index: 2001;
12741274
}
1275+
/* Focus waits on the box itself when a dialog opens (dialogs.js); no
1276+
ring round the whole dialog for that. */
1277+
.modal-box:focus {
1278+
outline: none;
1279+
}
12751280
.modal-box {
12761281
background: var(--card);
12771282
border: 1px solid rgba(128,128,128,0.3);
@@ -1822,6 +1827,10 @@ body.private-locked #networkTab {
18221827
text-decoration: underline;
18231828
color: var(--accent);
18241829
}
1830+
.item-history-link:focus-visible {
1831+
outline: 2px solid var(--accent);
1832+
outline-offset: 2px;
1833+
}
18251834
.progress-track {
18261835
height: 8px;
18271836
border-radius: 999px;

‎server/static/js/craft-history.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ function renderRow(e) {
138138
const name = escapeHtml(nameText(e.itemName || 'Unknown item'));
139139
// Rows saved before items were recorded with them can't open a chart.
140140
const title = e.internal
141-
? `<span class="item-history-link" data-mod="${escapeHtml(e.mod || '')}" data-internal="${escapeHtml(e.internal)}" data-damage="${e.damage != null ? escapeHtml(e.damage) : ''}" data-name="${escapeHtml(e.itemName || 'Unknown item')}" data-icon="${escapeHtml(e.icon || '')}">${name}</span>`
141+
? `<span class="item-history-link" tabindex="0" role="button" data-mod="${escapeHtml(e.mod || '')}" data-internal="${escapeHtml(e.internal)}" data-damage="${e.damage != null ? escapeHtml(e.damage) : ''}" data-name="${escapeHtml(e.itemName || 'Unknown item')}" data-icon="${escapeHtml(e.icon || '')}">${name}</span>`
142142
: `<span>${name}</span>`;
143143
const badge = (e.auto ? '<span class="status-badge auto" title="Started to keep this item in stock">Auto</span>' : '')
144144
+ (e.status === 'incomplete'

‎server/static/js/craft-stats.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ function itemRow(item, maxCount) {
6666
: '<span class="craft-icon"></span>';
6767
const name = escapeHtml(nameText(item.itemName || 'Unknown item'));
6868
const title = item.internal
69-
? `<span class="item-history-link" data-mod="${escapeHtml(item.mod || '')}" data-internal="${escapeHtml(item.internal)}" data-damage="${item.damage != null ? escapeHtml(item.damage) : ''}" data-name="${escapeHtml(item.itemName || 'Unknown item')}" data-icon="${escapeHtml(item.icon || '')}">${name}</span>`
69+
? `<span class="item-history-link" tabindex="0" role="button" data-mod="${escapeHtml(item.mod || '')}" data-internal="${escapeHtml(item.internal)}" data-damage="${item.damage != null ? escapeHtml(item.damage) : ''}" data-name="${escapeHtml(item.itemName || 'Unknown item')}" data-icon="${escapeHtml(item.icon || '')}">${name}</span>`
7070
: `<span>${name}</span>`;
7171
const details = [`${item.count}&times;`];
7272
if (item.medianSeconds != null) details.push(`usually ${formatDuration(item.medianSeconds)}`);

‎server/static/js/crafts.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -520,7 +520,7 @@ function renderCard(job) {
520520

521521
const title = job.busy
522522
? (job.final_output
523-
? `${job.final_output_icon ? `<img class="${iconClass('craft-icon', job.final_output_icon)}" src="${iconUrl(job.final_output_icon)}" alt="" loading="lazy" data-remove-on-error>` : ''}<span class="item-history-link" data-mod="${escapeHtml(job.final_output_mod || '')}" data-internal="${escapeHtml(job.final_output_internal || '')}" data-damage="${job.final_output_damage != null ? job.final_output_damage : ''}" data-name="${escapeHtml(job.final_output)}" data-icon="${escapeHtml(job.final_output_icon || '')}">${escapeHtml(nameText(job.final_output))}</span>`
523+
? `${job.final_output_icon ? `<img class="${iconClass('craft-icon', job.final_output_icon)}" src="${iconUrl(job.final_output_icon)}" alt="" loading="lazy" data-remove-on-error>` : ''}<span class="item-history-link"${job.final_output_internal ? ' tabindex="0" role="button"' : ''} data-mod="${escapeHtml(job.final_output_mod || '')}" data-internal="${escapeHtml(job.final_output_internal || '')}" data-damage="${job.final_output_damage != null ? job.final_output_damage : ''}" data-name="${escapeHtml(job.final_output)}" data-icon="${escapeHtml(job.final_output_icon || '')}">${escapeHtml(nameText(job.final_output))}</span>`
524524
: `<span class="craft-title-muted">${job.monitor_missing ? 'Unknown output' : 'Starting job…'}</span>`)
525525
: `<span class="craft-title-muted">Idle</span>`;
526526
// Only once the server is sure: a new job can report no output for a

‎server/static/js/dialogs.js‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Keyboard and screen-reader behaviour shared by every dialog
2+
// (.modal-overlay). The dialogs are shown and hidden by their own code
3+
// setting style.display, so this watches that rather than every open
4+
// and close function calling in:
5+
// - each box is marked up as a modal dialog, labelled by its title;
6+
// - opening one moves focus into it, unless its own code already did
7+
// (the craft dialog's amount box);
8+
// - Tab and Shift+Tab stay inside the top dialog;
9+
// - closing one puts focus back where it was before it opened, unless
10+
// focus has gone on somewhere else since (another dialog opened).
11+
12+
import { findCopy } from './util.js';
13+
14+
const FOCUSABLE = 'a[href], button, input, select, textarea, summary, [tabindex]:not([tabindex="-1"])';
15+
const openedFrom = new Map(); // overlay -> element focused before it opened
16+
let focused = null; // the element focused now,
17+
let focusedBefore = null; // and the one before it
18+
19+
function isShown(overlay) {
20+
return overlay.style.display !== 'none';
21+
}
22+
23+
// The dialog on top: the highest z-index, then the latest in the page.
24+
function topDialog() {
25+
let top = null;
26+
let topZ = -Infinity;
27+
for (const overlay of document.querySelectorAll('.modal-overlay')) {
28+
if (!isShown(overlay)) continue;
29+
const z = parseInt(getComputedStyle(overlay).zIndex, 10) || 0;
30+
if (z >= topZ) {
31+
top = overlay;
32+
topZ = z;
33+
}
34+
}
35+
return top;
36+
}
37+
38+
function focusables(box) {
39+
return [...box.querySelectorAll(FOCUSABLE)].filter(el =>
40+
!el.disabled && !el.closest('[hidden], [inert]') && el.getClientRects().length > 0);
41+
}
42+
43+
function markUp(overlay) {
44+
const box = overlay.querySelector('.modal-box');
45+
if (!box) return;
46+
box.setAttribute('role', 'dialog');
47+
box.setAttribute('aria-modal', 'true');
48+
box.tabIndex = -1; // focusable by script, so focus can wait in the dialog itself
49+
const title = box.querySelector('.modal-item-name, .craft-title');
50+
if (title) {
51+
if (!title.id) title.id = overlay.id + 'Title';
52+
box.setAttribute('aria-labelledby', title.id);
53+
}
54+
}
55+
56+
function onShown(overlay) {
57+
const active = document.activeElement;
58+
const box = overlay.querySelector('.modal-box') || overlay;
59+
if (active && overlay.contains(active)) {
60+
// Its own code focused something in it: what had focus before that.
61+
openedFrom.set(overlay, focusedBefore && !overlay.contains(focusedBefore) ? focusedBefore : null);
62+
return;
63+
}
64+
openedFrom.set(overlay, active && active !== document.body ? active : null);
65+
box.focus({ preventScroll: true });
66+
}
67+
68+
function onHidden(overlay) {
69+
const opener = openedFrom.get(overlay);
70+
// The Crafts tab and the ingredients grid are redrawn every few
71+
// seconds, so it may have been replaced by a copy of itself.
72+
const back = opener && !opener.isConnected ? findCopy(document, opener) : opener;
73+
openedFrom.delete(overlay);
74+
const active = document.activeElement;
75+
// Focus moved on to something else (another dialog's box): leave it.
76+
if (active && active !== document.body && !overlay.contains(active)) return;
77+
if (back && back.isConnected && back.getClientRects().length > 0) {
78+
back.focus({ preventScroll: true });
79+
return;
80+
}
81+
// Nothing to go back to: the dialog under it, if there is one.
82+
const under = topDialog();
83+
if (under) (under.querySelector('.modal-box') || under).focus({ preventScroll: true });
84+
else if (active && overlay.contains(active)) active.blur();
85+
}
86+
87+
function trapTab(e) {
88+
if (e.key !== 'Tab' || e.ctrlKey || e.metaKey || e.altKey) return;
89+
const overlay = topDialog();
90+
if (!overlay) return;
91+
const box = overlay.querySelector('.modal-box') || overlay;
92+
const items = focusables(box);
93+
if (!items.length) {
94+
e.preventDefault();
95+
box.focus({ preventScroll: true });
96+
return;
97+
}
98+
const first = items[0];
99+
const last = items[items.length - 1];
100+
const active = document.activeElement;
101+
const inside = box.contains(active) && active !== box;
102+
if (e.shiftKey && (!inside || active === first)) {
103+
e.preventDefault();
104+
last.focus();
105+
} else if (!e.shiftKey && (!inside || active === last)) {
106+
e.preventDefault();
107+
first.focus();
108+
}
109+
}
110+
111+
export function setupDialogs() {
112+
const observer = new MutationObserver((records) => {
113+
for (const { target, oldValue } of records) {
114+
const wasShown = !/display:\s*none/.test(oldValue || '');
115+
const shown = isShown(target);
116+
if (shown && !wasShown) onShown(target);
117+
else if (!shown && wasShown) onHidden(target);
118+
}
119+
});
120+
for (const overlay of document.querySelectorAll('.modal-overlay')) {
121+
markUp(overlay);
122+
observer.observe(overlay, { attributes: true, attributeFilter: ['style'], attributeOldValue: true });
123+
}
124+
document.addEventListener('keydown', trapTab);
125+
document.addEventListener('focusin', (e) => {
126+
if (e.target === focused) return;
127+
focusedBefore = focused;
128+
focused = e.target;
129+
});
130+
}

‎server/static/js/history.js‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -148,21 +148,34 @@ function readDamageFromDataset(raw) {
148148
return isNaN(n) ? null : n;
149149
}
150150

151+
function openItemFromLink(link) {
152+
openItemHistoryFromCraft(
153+
link.dataset.mod || null,
154+
link.dataset.internal || null,
155+
readDamageFromDataset(link.dataset.damage),
156+
link.dataset.name || '?',
157+
link.dataset.icon || null);
158+
}
159+
151160
export function setupCraftHistoryLinks() {
152161
// Event delegation, same reasoning as the network grid's tooltip -
153162
// the crafts container, the ingredients grid and the craft history
154163
// are rebuilt wholesale via innerHTML on every poll, so per-element
155164
// listeners would just be discarded each time.
156165
for (const id of ['root', 'ingredientsModal', 'craftHistory', 'craftStats']) {
157-
document.getElementById(id).addEventListener('click', (e) => {
166+
const container = document.getElementById(id);
167+
container.addEventListener('click', (e) => {
158168
const link = e.target.closest('.item-history-link');
159-
if (!link) return;
160-
openItemHistoryFromCraft(
161-
link.dataset.mod || null,
162-
link.dataset.internal || null,
163-
readDamageFromDataset(link.dataset.damage),
164-
link.dataset.name || '?',
165-
link.dataset.icon || null);
169+
if (link) openItemFromLink(link);
170+
});
171+
// Focusable links (tabindex) open with Enter or Space, as a button does.
172+
container.addEventListener('keydown', (e) => {
173+
if (e.key !== 'Enter' && e.key !== ' ') return;
174+
if (e.ctrlKey || e.metaKey || e.altKey) return;
175+
const link = e.target.closest('.item-history-link[tabindex]');
176+
if (!link || link !== e.target) return;
177+
e.preventDefault(); // Space would scroll the page
178+
openItemFromLink(link);
166179
});
167180
}
168181
}

‎server/static/js/ingredients.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@ function cellHtml(m) {
100100
const linkAttrs = `data-mod="${escapeHtml(m.mod || '')}" data-internal="${escapeHtml(m.internal || '')}" `
101101
+ `data-damage="${m.damage != null ? m.damage : ''}" data-name="${escapeHtml(m.name || '?')}" `
102102
+ `data-icon="${escapeHtml(m.icon || '')}"`;
103-
return `<div class="ingredient-cell item-history-link${m.crafting > 0 ? ' crafting' : ''}" data-key="${escapeHtml(m.key)}" ${linkAttrs}>
103+
const focusable = m.internal ? ' tabindex="0" role="button"' : '';
104+
return `<div class="ingredient-cell item-history-link${m.crafting > 0 ? ' crafting' : ''}"${focusable} data-key="${escapeHtml(m.key)}" ${linkAttrs}>
104105
<div class="ingredient-cell-lines">${name}${lines}</div>${icon}
105106
</div>`;
106107
}

‎server/static/js/main.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import { setupCraftHistory } from './craft-history.js';
1212
import { setupAgentsActions } from './agents.js';
1313
import { setupCraftStats } from './craft-stats.js';
14+
import { setupDialogs } from './dialogs.js';
1415
import { setupCraftPlanActions } from './craft-plan.js';
1516
import { pollCrafts, refresh, setupCraftsActions, setupNotifications, tickSourceLine } from './crafts.js';
1617
import { setupGameDataActions } from './gamedata.js';
@@ -41,6 +42,7 @@ window.gcmStarted = true;
4142
// and the render functions only carry data-action attributes.
4243
removeLegacyStorageKeys();
4344
setupImageErrorRemoval();
45+
setupDialogs();
4446
setupAuthControls();
4547
setupTheme();
4648
setupAdminActions();

‎server/static/js/util.js‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,18 @@ export function bindFilterInput(input, onChange) {
138138
// every second or so made them stutter; a moved one keeps going. Only
139139
// within this document, though: a template's content is a document of its
140140
// own, and an <img> moved through one restarts too - hence importNode.
141+
// The element in root that a redraw made in place of el: same tag and
142+
// the same identifying attributes. null when el has none of them.
143+
const IDENTITY = ['id', 'data-action', 'data-cpu', 'data-key', 'data-mod', 'data-internal', 'data-damage', 'data-tab', 'data-view'];
144+
145+
export function findCopy(root, el) {
146+
const attrs = IDENTITY.filter(a => el.hasAttribute(a));
147+
if (!attrs.length) return null;
148+
const selector = el.tagName.toLowerCase()
149+
+ attrs.map(a => `[${a}="${CSS.escape(el.getAttribute(a))}"]`).join('');
150+
return root.querySelector(selector);
151+
}
152+
141153
export function setHtmlKeepingIcons(el, html) {
142154
const old = new Map();
143155
for (const img of el.querySelectorAll('img[src^="/icons?"]')) {
@@ -154,7 +166,11 @@ export function setHtmlKeepingIcons(el, html) {
154166
kept.className = img.className;
155167
img.replaceWith(kept);
156168
}
169+
// Focus stays on the same control through a redraw, for the keyboard.
170+
const active = document.activeElement;
171+
const hadFocus = active && active !== el && el.contains(active) ? active : null;
157172
el.replaceChildren(content);
173+
if (hadFocus) findCopy(el, hadFocus)?.focus({ preventScroll: true });
158174
}
159175

160176
// <img data-remove-on-error> removes itself when it fails to load (an

0 commit comments

Comments
 (0)