diff --git a/docs/PLAYING.md b/docs/PLAYING.md
index c35229e5..4ed62f68 100644
--- a/docs/PLAYING.md
+++ b/docs/PLAYING.md
@@ -171,7 +171,7 @@
| `Space` | 准备就绪 / 取消准备;独立模拟的作战中暂停 / 继续 |
| `Esc` | 取消 / 关闭:依次关闭表情面板、敌人预览、盟约浮层、详情卡、本局信息 / 敌方情报(固定,不能修改) |
-刷新、冻结、升级、撤退、出售和准备只在休整期有效,撤退 / 出售要先点选一名干员。方向轮盘(方向键预览、`Enter` 确认、`Esc` 取消)、「玩法说明」和「干员调配」的翻页键是这些界面自己的按键,不能修改。
+刷新、冻结、升级、撤退、出售和准备只在休整期有效,撤退 / 出售要先点选一名干员。方向轮盘(方向键预览、`Enter` 确认、`Esc` 取消;按 Tab 先到轮盘上的「点击取消」,在它上面按 `Enter` 是取消,Tab 到其他按钮上按 `Enter` 没有反应)、「玩法说明」和「干员调配」的翻页键是这些界面自己的按键,不能修改。
**修改快捷键**:标题画面的「设置」按钮、大厅和房间顶栏的「设置」(玩法说明左边)或对局左下角的「设置」→「快捷键」。点一下要改的按键(只用键盘时按 Tab 移到它上面再按 Enter),出现「按下新按键…」后按新的键;按 `Esc` 取消,设置窗口不会关闭。可以用字母、数字、空格、标点符号键和 Delete / Insert / Home / End / PageUp / PageDown;`Esc`、`Tab`、`Enter`、方向键和 F1–F12 留给界面使用,也不能搭配 Ctrl / Alt / ⌘。新按键已被另一个快捷键使用时两者互换,列表下方会写明(例如把「撤退选中干员」改成 `X`,「出售选中干员」就换成原来的 `Q`)。「恢复默认」改回上表的按键。修改立即生效,商店栏、准备按钮和单位底部「撤退 / 出售」按钮上显示的按键也跟着变;设置和音量等一起保存在当前浏览器里(换浏览器或清除网站数据后回到默认)。按键按键帽上印的字符识别(AZERTY 等键盘按印着的字母),俄文等非拉丁布局按键位识别。手机等纯触屏设备要连接键盘才能用快捷键,设置里的快捷键列表默认折叠。
diff --git a/public/css/screens/game-panels.css b/public/css/screens/game-panels.css
index 1cda7adb..94773851 100644
--- a/public/css/screens/game-panels.css
+++ b/public/css/screens/game-panels.css
@@ -515,6 +515,8 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
/* ---- direction wheel (ui/facingWheel.js, research 09 §1.2): white diamond, 4 chevrons, "✕ 点击取消", orange range ---- */
.fwheel { position: fixed; inset: 0; z-index: 6; touch-action: none; user-select: none; -webkit-user-select: none; cursor: crosshair; }
+/* the wheel takes the keyboard focus when it opens (Enter confirms there, GitHub #394): no ring around the whole screen */
+.fwheel:focus-visible { outline: none; }
.fwheel__stripes { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
.fwheel__dia { position: absolute; transform: translate(-50%, -50%); pointer-events: none; animation: fwheel-in 160ms var(--ease-out); }
@keyframes fwheel-in { from { opacity: 0; transform: translate(-50%, -50%) scale(.82); } }
diff --git a/public/js/ui/facingWheel.js b/public/js/ui/facingWheel.js
index a4987ccf..c688a1d8 100644
--- a/public/js/ui/facingWheel.js
+++ b/public/js/ui/facingWheel.js
@@ -13,7 +13,8 @@
// LEFT, facing.js boardDir): the chevrons follow the finger, the range / model / intent use the board direction;
// * release outside the centre commits (g.move {uid, to, dir} / g.art {…, dir}); release inside the centre, a tap
// on ✕, a press outside the diamond or Esc cancels (the piece returns to where it came from). Arrow keys preview a
-// direction and Enter commits it (keyboard alternative). Mouse, pen and touch all use pointer events.
+// direction and Enter commits it (keyboard alternative) — Enter on a focused ✕ cancels, on any other focused
+// button does nothing while the wheel is open (GitHub #394). Mouse, pen and touch all use pointer events.
//
// View bridge: the field view (render/app.js or the DOM fallback) may implement `tileScreen(row, col)`,
// `holdPiece(uid, tile|null)` and `setPieceDir(uid, dir)` (render/app.js does, DESIGN §9); a view without them falls
@@ -25,7 +26,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from '../../vendor/hooks
import { html } from './components.js';
import { LocalSprite } from './gameComponents.js';
import { DIRS, DIR_LABEL, DEAD_ZONE_TILES, dirFromDelta, dirFromKey, rangeTiles, normDir, boardDir, viewMirrored } from './facing.js';
-import { facingSwallows } from './gameLogic.js';
+import { facingSwallows, facingEnter } from './gameLogic.js';
import { settingsStore } from './settings.js';
import { t } from '../../../shared/i18n.js';
@@ -236,24 +237,36 @@ export function FacingWheel({ view, row, col, grid, name = '', onPreview, onComm
};
const onPointerCancel = () => { setDrag(null); setDir(null); };
- // keyboard: arrows preview, Enter commits, Esc cancels (capture: the game's own shortcuts must not see them)
+ // keyboard: arrows preview, Enter commits, Esc cancels (capture: the game's own shortcuts must not see them). Enter
+ // follows the focus (gameLogic facingEnter, GitHub #394): on ✕ it cancels, on any other button (Tab reaches the HUD
+ // behind the wheel) it does nothing, elsewhere it commits the previewed direction. The wheel takes the focus when it
+ // opens, so a button focused before the drop (a click leaves it focused) cannot swallow the confirming Enter; Tab then
+ // reaches ✕ first. Nothing gives the focus back when it closes (a restored 准备 would take the next Enter / Space).
+ const rootRef = useRef(null);
useEffect(() => {
const onKey = (e) => {
const L = live.current;
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); L.onCancel(); return; }
const k = dirFromKey(e.key);
if (k) { e.preventDefault(); e.stopImmediatePropagation(); setDir(k); return; }
- if (e.key === 'Enter' && L.dir) { e.preventDefault(); e.stopImmediatePropagation(); L.onCommit(L.bdir); return; }
+ const enter = facingEnter(e, !!L.dir);
+ if (enter) {
+ e.preventDefault(); e.stopImmediatePropagation();
+ if (enter === 'cancel') L.onCancel();
+ else if (enter === 'commit') L.onCommit(L.bdir);
+ return;
+ }
// no ready / shop while choosing: Space and every key of the player's map (设置 → 快捷键)
if (facingSwallows(e, settingsStore.get().keys)) { e.preventDefault(); e.stopImmediatePropagation(); }
};
window.addEventListener('keydown', onKey, true);
+ try { rootRef.current?.focus({ preventScroll: true }); } catch { /* no focus: Enter still follows whatever has it */ }
return () => window.removeEventListener('keydown', onKey, true);
}, []);
const outside = !!drag && !!dir;
const box = half * 2;
- return html`
{ e.preventDefault(); onCancel(); }}>
${g ? html`<${Stripes} tiles=${tiles} view=${view} row=${row} col=${col} />` : null}
diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js
index 9d871918..8be1a0cf 100644
--- a/public/js/ui/gameLogic.js
+++ b/public/js/ui/gameLogic.js
@@ -39,7 +39,7 @@ export { terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits } from './gameLog
export { normalizeDraft, normalizeSp } from './gameLogic/draft.js';
export { groupEnemies, PEN, penZoneTiles, penPlacement, previewEnemyKey, factionTypes } from './gameLogic/enemies.js';
export { snapHud, bossFrac, bossPctText, hasFlag, UF, attackInterval, fmtNum, rangeGridBox } from './gameLogic/format.js';
-export { shortcutFor, closesOnFieldPress, shortcutBlocked, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, isBindableCode, hotkeyLabel, sanitizeHotkeys, rebindHotkey, isDefaultHotkeys, hotkeyOf, actionForKey, captureHotkey, facingSwallows } from './gameLogic/shortcuts.js';
+export { shortcutFor, closesOnFieldPress, shortcutBlocked, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, isBindableCode, hotkeyLabel, sanitizeHotkeys, rebindHotkey, isDefaultHotkeys, hotkeyOf, actionForKey, captureHotkey, facingSwallows, facingEnter } from './gameLogic/shortcuts.js';
export { DEFAULT_SETTINGS, VOICE_LANGS, sanitizeSettings } from './gameLogic/settings.js';
export { normalizeResult } from './gameLogic/result.js';
export { chessLoadout, unitLoadout } from './gameLogic/loadout.js';
diff --git a/public/js/ui/gameLogic/shortcuts.js b/public/js/ui/gameLogic/shortcuts.js
index 5042f495..a6b74409 100644
--- a/public/js/ui/gameLogic/shortcuts.js
+++ b/public/js/ui/gameLogic/shortcuts.js
@@ -140,8 +140,8 @@ export function captureHotkey(e) {
* Map a keydown to a game shortcut under the player's key map (defaults: R refresh, F freeze, D level-up, Q retreat,
* X sell, Space ready) or Esc (close; fixed). A shortcut key means its action even while a HUD button has focus (a
* mouse click leaves the shop card / 刷新 focused, and Space must not re-trigger it); the caller prevents the button's
- * own activation. Enter still activates buttons (it cannot be a shortcut). Nothing while Ctrl / ⌘ / Alt is held or a
- * text field has the focus.
+ * own activation. Enter still activates buttons (it cannot be a shortcut; behind the facing wheel only its ✕:
+ * facingEnter). Nothing while Ctrl / ⌘ / Alt is held or a text field has the focus.
* @param {{ key?: string, code?: string, ctrlKey?: boolean, metaKey?: boolean, altKey?: boolean, repeat?: boolean, target?: any }} e
* @param {any} [keys] the player's map (settings `keys`)
* @returns {'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready'|'escape'|null}
@@ -164,6 +164,27 @@ export function shortcutFor(e, keys = DEFAULT_HOTKEYS) {
*/
export const facingSwallows = (e, keys = DEFAULT_HOTKEYS) => e?.key === ' ' || actionForKey(e, keys) != null;
+// what a focused element is when Enter would activate it (a button, a link, a form field, an ARIA control)
+const CONTROL = 'button, a[href], input, select, textarea, summary, [contenteditable=""], [contenteditable="true"], '
+ + '[role="button"], [role="link"], [role="checkbox"], [role="switch"], [role="tab"], [role="menuitem"], [role="option"]';
+
+/**
+ * What Enter does while the facing wheel is open (GitHub #394, PR #395): on the wheel's own ✕ button it cancels; on
+ * any other control (准备, 刷新, 设置, 玩法说明, a shop card … — Tab reaches them behind the wheel) nothing happens — the
+ * wheel holds the keyboard as it holds Space and the shortcuts; with the focus anywhere else (the wheel itself, the
+ * page) it confirms the previewed direction, and does nothing while none is previewed. null: not Enter.
+ * @param {{ key?: string, target?: any }} e @param {boolean} chosen a direction is previewed
+ * @returns {'cancel'|'commit'|'swallow'|null}
+ */
+export function facingEnter(e, chosen) {
+ if (e?.key !== 'Enter') return null;
+ const t = e.target;
+ const closest = (sel) => (t && typeof t.closest === 'function' ? t.closest(sel) : null);
+ if (closest('.fwheel__cancel')) return 'cancel';
+ if (closest(CONTROL)) return 'swallow';
+ return chosen ? 'commit' : 'swallow';
+}
+
/**
* Whether a press on the field closes the open detail card: a card opened from the field itself (an own piece — tap,
* right-click or long press — or a battle / teammate unit). Shop, reward, bond-member and intel (enemy) cards stay.
diff --git a/test/ui/facing.e2e.test.js b/test/ui/facing.e2e.test.js
new file mode 100644
index 00000000..324bb7e3
--- /dev/null
+++ b/test/ui/facing.e2e.test.js
@@ -0,0 +1,157 @@
+// The facing wheel's keyboard in the mock match (headless Chrome, the DOM field — no art / WebGL needed): GitHub #394 /
+// PR #395 (the drag / Tab flow of this file is the PR's, by @Xinglan233). Enter follows the focus while the wheel is
+// open — the wheel takes the focus when it opens, so arrows + Enter confirm (also with a HUD button focused before the
+// drop); Tab reaches ✕ first and Enter on it cancels; Enter on any other button (准备, 设置 …) does nothing; Esc cancels.
+// Unit side: test/ui/feedback5-hotkeys.test.js (facingEnter).
+// SP_E2E=1 CHROME_PATH=/path/to/chrome node --test test/ui/facing.e2e.test.js
+import { test, describe, before, after } from 'node:test';
+import assert from 'node:assert/strict';
+import { existsSync } from 'node:fs';
+
+const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
+const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+describe('facing wheel keyboard (mock match, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
+ let srv, browser;
+ before(async () => {
+ const { startServer } = await import('../../server/index.js');
+ const puppeteer = (await import('puppeteer-core')).default;
+ srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
+ browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--no-proxy-server'] });
+ });
+ after(async () => { await browser?.close(); await srv?.close(); });
+
+ async function setup(t) {
+ const page = await browser.newPage();
+ t.after(() => page.close());
+ await page.setViewport({ width: 1920, height: 1080 });
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.goto(`http://127.0.0.1:${srv.port}/dev/game-mock.html?shot=1&render=fallback&phase=PREP`, { waitUntil: 'networkidle0' });
+ await page.waitForSelector('.ff-piece');
+ const state = () => page.evaluate(() => JSON.parse(JSON.stringify(globalThis.__MOCK__.S().priv)));
+ const moves = () => page.evaluate(() => globalThis.__MOCK__.S().requests.filter(([type]) => type === 'g.move'));
+ const sent = () => page.evaluate(() => globalThis.__MOCK__.S().requests.map(([type]) => type));
+ const initial = await state();
+ // room under the deploy cap (sell a board operator through the underframe), then a free legal tile
+ const sell = initial.board.find((p) => p.kind === 'chess' && !p.golden);
+ await page.click(`.ff-piece[data-uid="${sell.uid}"]`);
+ await page.waitForSelector('.uframe__btn--sell');
+ await page.click('.uframe__btn--sell');
+ await page.waitForFunction((uid) => !globalThis.__MOCK__.S().priv.board.some((p) => p.uid === uid), {}, sell.uid);
+ const ready = await state();
+ const piece = ready.hand.find((p) => p?.kind === 'chess');
+ const tile = [[9, 9], [9, 8], [12, 6], [11, 7], [10, 7], [9, 7]].find(([r, c]) => !ready.board.some((p) => p.row === r && p.col === c));
+ assert.ok(piece && tile, 'an operator and a free legal tile exist');
+ const center = (sel) => page.$eval(sel, (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
+ const open = async () => {
+ const from = await center(`.ff-piece[data-uid="${piece.uid}"]`);
+ const to = await center(`.ff-tile[data-row="${tile[0]}"][data-col="${tile[1]}"]`);
+ await page.mouse.move(from.x, from.y); await page.mouse.down();
+ await page.mouse.move(from.x + 20, from.y, { steps: 4 }); // a drag also when the piece re-orients on its own tile
+ await page.mouse.move(to.x, to.y, { steps: 12 }); await sleep(80); await page.mouse.up();
+ await page.waitForSelector('.fwheel__dia');
+ await sleep(200);
+ };
+ const preview = async (key) => {
+ await page.keyboard.press(key);
+ await page.waitForSelector(`.fwheel.is-${key.slice(5).toLowerCase()}`);
+ };
+ const active = () => page.evaluate(() => {
+ const a = document.activeElement;
+ return { wheel: !!a?.matches('.fwheel'), cancel: !!a?.closest('.fwheel__cancel'), button: a?.tagName === 'BUTTON' && !a.closest('.fwheel'), cls: a?.className || a?.tagName };
+ });
+ const tabUntil = async (want) => {
+ for (let i = 0; i < 80; i++) {
+ if (want(await active())) return;
+ await page.keyboard.press('Tab');
+ }
+ assert.fail('Tab never reached the wanted element');
+ };
+ const isOpen = () => page.evaluate(() => !!document.querySelector('.fwheel'));
+ const closed = async () => { await page.waitForFunction(() => !document.querySelector('.fwheel')); await sleep(150); };
+ return { page, state, moves, sent, errors, ready, piece, tile, open, preview, active, tabUntil, isOpen, closed };
+ }
+
+ test('the wheel takes the focus: arrows + Enter confirm once in all four directions', async (t) => {
+ const h = await setup(t);
+ for (const dir of ['UP', 'RIGHT', 'DOWN', 'LEFT']) {
+ await h.open();
+ assert.equal((await h.active()).wheel, true, 'the wheel has the focus when it opens');
+ await h.page.keyboard.press('Enter');
+ await sleep(150);
+ assert.equal(await h.isOpen(), true, 'Enter with no direction previewed does nothing');
+ await h.preview(`Arrow${dir[0] + dir.slice(1).toLowerCase()}`);
+ const count = (await h.moves()).length;
+ await h.page.keyboard.press('Enter');
+ await h.closed();
+ assert.equal((await h.moves()).length, count + 1, 'one intent per confirmation');
+ assert.deepEqual((await h.moves()).at(-1)[1].dir, dir);
+ const board = (await h.state()).board.filter((p) => p.uid === h.piece.uid);
+ assert.deepEqual([board.length, board[0].row, board[0].col, board[0].dir], [1, ...h.tile, dir]);
+ }
+ assert.deepEqual(h.errors, []);
+ });
+
+ test('Tab reaches ✕ first and Enter on it cancels; Esc cancels; Space on ✕ stays swallowed', async (t) => {
+ const h = await setup(t);
+ await h.open();
+ await h.preview('ArrowRight');
+ await h.page.keyboard.press('Tab');
+ assert.equal((await h.active()).cancel, true, 'the first Tab from the wheel lands on ✕');
+ await h.page.keyboard.press('Space');
+ await sleep(150);
+ assert.equal(await h.isOpen(), true, 'Space does not activate the focused ✕ (no ready / shop keys while choosing)');
+ await h.page.keyboard.press('Enter');
+ await h.closed();
+ assert.deepEqual(await h.moves(), [], 'cancelling sends no deployment');
+ assert.deepEqual(await h.state(), h.ready, 'the operator stays where it was');
+ await h.open();
+ await h.preview('ArrowLeft');
+ await h.page.keyboard.press('Escape');
+ await h.closed();
+ assert.deepEqual(await h.moves(), []);
+ assert.deepEqual(h.errors, []);
+ });
+
+ test('Enter on another focused button (准备, 设置 …) does nothing while the wheel is open', async (t) => {
+ const h = await setup(t);
+ await h.open();
+ await h.preview('ArrowRight');
+ const before = await h.sent();
+ // walk the focus past ✕ to the HUD behind the wheel: every button reached gets an Enter
+ const seen = new Set();
+ for (let i = 0; i < 80 && seen.size < 6; i++) {
+ await h.page.keyboard.press('Tab');
+ const a = await h.active();
+ if (!a.button || seen.has(a.cls)) continue;
+ seen.add(a.cls);
+ await h.page.keyboard.press('Enter');
+ await sleep(120);
+ assert.equal(await h.isOpen(), true, `Enter on ${a.cls} keeps the wheel open`);
+ assert.equal(await h.page.evaluate(() => !!document.querySelector('.modal')), false, `Enter on ${a.cls} opens nothing`);
+ }
+ assert.ok(seen.size >= 2, `Tab reached HUD buttons behind the wheel (${[...seen].join(' | ')})`);
+ assert.ok([...seen].some((c) => /readybtn|gm__gear/.test(c)), '准备 or 设置 among them');
+ assert.deepEqual(await h.sent(), before, 'no intent of any kind (no g.move, no ready, no shop)');
+ assert.equal((await h.state()).ready, h.ready.ready, 'still not ready');
+ await h.page.keyboard.press('Escape');
+ await h.closed();
+ assert.deepEqual(await h.moves(), []);
+ assert.deepEqual(h.errors, []);
+ });
+
+ test('a HUD button focused before the drop does not swallow the confirming Enter', async (t) => {
+ const h = await setup(t);
+ await h.page.focus('.gm__gear');
+ await h.open();
+ assert.equal((await h.active()).wheel, true);
+ await h.preview('ArrowDown');
+ await h.page.keyboard.press('Enter');
+ await h.closed();
+ assert.equal((await h.moves()).length, 1);
+ assert.equal(await h.page.evaluate(() => !!document.querySelector('.modal')), false, 'the settings did not open');
+ assert.deepEqual(h.errors, []);
+ });
+});
diff --git a/test/ui/feedback5-hotkeys.test.js b/test/ui/feedback5-hotkeys.test.js
index 5d106ac2..d598ce13 100644
--- a/test/ui/feedback5-hotkeys.test.js
+++ b/test/ui/feedback5-hotkeys.test.js
@@ -11,7 +11,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
HOTKEY_ACTIONS, DEFAULT_HOTKEYS, isBindableCode, hotkeyLabel, sanitizeHotkeys, rebindHotkey, isDefaultHotkeys, hotkeyOf,
- actionForKey, captureHotkey, facingSwallows, shortcutFor, shortcutBlocked, sanitizeSettings, DEFAULT_SETTINGS,
+ actionForKey, captureHotkey, facingSwallows, facingEnter, shortcutFor, shortcutBlocked, sanitizeSettings, DEFAULT_SETTINGS,
} from '../../public/js/ui/gameLogic.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
@@ -201,6 +201,59 @@ describe('the one lookup (actionForKey) and shortcutFor', () => {
assert.equal(facingSwallows({ key: ' ', code: 'Space' }, { ...DEFAULT_HOTKEYS, ready: 'KeyE' }), true, 'Space always: a focused button must not activate');
assert.equal(facingSwallows({ key: 'z', code: 'KeyZ' }), false);
});
+
+ // GitHub #394 / PR #395: Enter followed the previewed direction whatever had the focus — Tab to 准备 / 设置 / ✕ and
+ // Enter deployed. A tiny element tree whose closest() understands the selector forms facingEnter uses.
+ const el = (tag, attrs = {}, parent = null) => {
+ const self = {
+ tagName: tag.toUpperCase(), attrs, parent,
+ matches(sel) {
+ return sel.split(',').map((s) => s.trim()).some((s) => {
+ const m = /^([a-z]*)((?:\.[\w-]+)*)((?:\[[\w-]+(?:="[^"]*")?\])*)$/.exec(s);
+ if (!m) return false;
+ if (m[1] && m[1].toUpperCase() !== self.tagName) return false;
+ const classes = String(attrs.class || '').split(/\s+/);
+ if (m[2] && !m[2].slice(1).split('.').every((c) => classes.includes(c))) return false;
+ for (const [, k, v] of m[3].matchAll(/\[([\w-]+)(?:="([^"]*)")?\]/g)) {
+ if (!(k in attrs) || (v !== undefined && String(attrs[k]) !== v)) return false;
+ }
+ return !!(m[1] || m[2] || m[3]);
+ });
+ },
+ closest(sel) { for (let n = self; n; n = n.parent) if (n.matches(sel)) return n; return null; },
+ };
+ return self;
+ };
+ test('the facing wheel\'s Enter follows the focus: ✕ cancels, another control does nothing, elsewhere it commits', () => {
+ const body = el('body');
+ const wheel = el('div', { class: 'fwheel', role: 'dialog', tabindex: '-1' }, body);
+ const cancel = el('button', { class: 'fwheel__cancel' }, el('div', { class: 'fwheel__dia' }, wheel));
+ const cancelText = el('span', {}, cancel);
+ const ready = el('button', { class: 'ready' }, el('header', {}, body));
+ const gear = el('button', { class: 'gm__gear' }, body);
+ const card = el('div', { role: 'button', tabindex: '0' }, body);
+ const link = el('a', { href: '#' }, body);
+ const field = el('input', { type: 'text' }, body);
+ for (const chosen of [true, false]) {
+ assert.equal(facingEnter({ key: 'Enter', target: cancel }, chosen), 'cancel', 'Enter on ✕ cancels (a direction previewed or not)');
+ assert.equal(facingEnter({ key: 'Enter', target: cancelText }, chosen), 'cancel');
+ for (const t of [ready, gear, card, link, field]) assert.equal(facingEnter({ key: 'Enter', target: t }, chosen), 'swallow', 'another control does nothing');
+ }
+ assert.equal(facingEnter({ key: 'Enter', target: wheel }, true), 'commit', 'the wheel has the focus when it opens: Enter confirms');
+ assert.equal(facingEnter({ key: 'Enter', target: body }, true), 'commit');
+ assert.equal(facingEnter({ key: 'Enter', target: null }, true), 'commit');
+ assert.equal(facingEnter({ key: 'Enter', target: wheel }, false), 'swallow', 'nothing to confirm yet');
+ assert.equal(facingEnter({ key: ' ', target: cancel }, true), null, 'not Enter: Space stays with facingSwallows');
+ assert.equal(facingEnter({ key: 'ArrowUp', target: body }, true), null);
+ assert.equal(facingEnter(null, true), null);
+ });
+ test('the wheel wires it: Enter through facingEnter, the wheel focusable and focused when it opens', () => {
+ const src = read('public/js/ui/facingWheel.js');
+ assert.match(src, /const enter = facingEnter\(e, !!L\.dir\);/);
+ assert.doesNotMatch(src, /e\.key === 'Enter' && L\.dir/, 'no Enter that ignores the focus');
+ assert.match(src, /
{