feedback7 R22-LATE: while a facing is chosen, Enter follows the focus — ✕ cancels, any other button does nothing, the wheel confirms; golden: unchanged
GitHub #394 / PR #395 by @Xinglan233. The wheel's capture listener committed the previewed direction on Enter whatever had the focus, so Tab to 准备 / 设置 / the exit button / ✕ and Enter deployed (the PR let only a focused ✕ keep its native Enter). Now gameLogic facingEnter decides: Enter on ✕ cancels; on any other control (button, link, form field, ARIA control) it is swallowed — the wheel already holds Space and the shortcuts; elsewhere it commits the previewed direction, and does nothing while none is previewed. The wheel (role="dialog") takes the focus when it opens (tabIndex -1, no focus ring over the screen), so a button focused before the drop cannot swallow the confirming Enter and the first Tab reaches ✕; nothing restores the old focus when it closes (a restored 准备 would take the next key). Docs: PLAYING.md's key paragraph. Tests: test/ui/feedback5-hotkeys.test.js (facingEnter and its wiring, 2 new); test/ui/facing.e2e.test.js (SP_E2E, the PR's drag / Tab flow, 4 cases: run here in headless Chrome, 4/4 pass, 4/4 fail on the parent — Enter on the focused exit button deployed). Golden unchanged (client only).
This commit is contained in:
1 parent
2df1cf15bf
commit
a1bc37ed90
7 files changed
+256
-10
No files matched your search
+1
-1
@@ -171,7 +171,7 @@
|
|||||||
| `Space` | 准备就绪 / 取消准备;独立模拟的作战中暂停 / 继续 |
|
| `Space` | 准备就绪 / 取消准备;独立模拟的作战中暂停 / 继续 |
|
||||||
| `Esc` | 取消 / 关闭:依次关闭表情面板、敌人预览、盟约浮层、详情卡、本局信息 / 敌方情报(固定,不能修改) |
|
| `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 等键盘按印着的字母),俄文等非拉丁布局按键位识别。手机等纯触屏设备要连接键盘才能用快捷键,设置里的快捷键列表默认折叠。
|
**修改快捷键**:标题画面的「设置」按钮、大厅和房间顶栏的「设置」(玩法说明左边)或对局左下角的「设置」→「快捷键」。点一下要改的按键(只用键盘时按 Tab 移到它上面再按 Enter),出现「按下新按键…」后按新的键;按 `Esc` 取消,设置窗口不会关闭。可以用字母、数字、空格、标点符号键和 Delete / Insert / Home / End / PageUp / PageDown;`Esc`、`Tab`、`Enter`、方向键和 F1–F12 留给界面使用,也不能搭配 Ctrl / Alt / ⌘。新按键已被另一个快捷键使用时两者互换,列表下方会写明(例如把「撤退选中干员」改成 `X`,「出售选中干员」就换成原来的 `Q`)。「恢复默认」改回上表的按键。修改立即生效,商店栏、准备按钮和单位底部「撤退 / 出售」按钮上显示的按键也跟着变;设置和音量等一起保存在当前浏览器里(换浏览器或清除网站数据后回到默认)。按键按键帽上印的字符识别(AZERTY 等键盘按印着的字母),俄文等非拉丁布局按键位识别。手机等纯触屏设备要连接键盘才能用快捷键,设置里的快捷键列表默认折叠。
|
||||||
|
|
||||||
|
|||||||
@@ -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 ---- */
|
/* ---- 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; }
|
.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__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); }
|
.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); } }
|
@keyframes fwheel-in { from { opacity: 0; transform: translate(-50%, -50%) scale(.82); } }
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
// LEFT, facing.js boardDir): the chevrons follow the finger, the range / model / intent use the board direction;
|
// 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
|
// * 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
|
// 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)`,
|
// 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
|
// `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 { html } from './components.js';
|
||||||
import { LocalSprite } from './gameComponents.js';
|
import { LocalSprite } from './gameComponents.js';
|
||||||
import { DIRS, DIR_LABEL, DEAD_ZONE_TILES, dirFromDelta, dirFromKey, rangeTiles, normDir, boardDir, viewMirrored } from './facing.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 { settingsStore } from './settings.js';
|
||||||
import { t } from '../../../shared/i18n.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); };
|
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(() => {
|
useEffect(() => {
|
||||||
const onKey = (e) => {
|
const onKey = (e) => {
|
||||||
const L = live.current;
|
const L = live.current;
|
||||||
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); L.onCancel(); return; }
|
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); L.onCancel(); return; }
|
||||||
const k = dirFromKey(e.key);
|
const k = dirFromKey(e.key);
|
||||||
if (k) { e.preventDefault(); e.stopImmediatePropagation(); setDir(k); return; }
|
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 (设置 → 快捷键)
|
// no ready / shop while choosing: Space and every key of the player's map (设置 → 快捷键)
|
||||||
if (facingSwallows(e, settingsStore.get().keys)) { e.preventDefault(); e.stopImmediatePropagation(); }
|
if (facingSwallows(e, settingsStore.get().keys)) { e.preventDefault(); e.stopImmediatePropagation(); }
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKey, true);
|
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);
|
return () => window.removeEventListener('keydown', onKey, true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const outside = !!drag && !!dir;
|
const outside = !!drag && !!dir;
|
||||||
const box = half * 2;
|
const box = half * 2;
|
||||||
return html`<div class=${cx('fwheel', drag && 'is-pressed', dir && `is-${dir.toLowerCase()}`)} role="dialog" aria-label=${name ? t('选择「{name}」的朝向', { name }) : t('选择朝向')}
|
return html`<div ref=${rootRef} tabIndex="-1" class=${cx('fwheel', drag && 'is-pressed', dir && `is-${dir.toLowerCase()}`)} role="dialog" aria-label=${name ? t('选择「{name}」的朝向', { name }) : t('选择朝向')}
|
||||||
onPointerDown=${onDown} onPointerMove=${onMove} onPointerUp=${onUp} onPointerCancel=${onPointerCancel}
|
onPointerDown=${onDown} onPointerMove=${onMove} onPointerUp=${onUp} onPointerCancel=${onPointerCancel}
|
||||||
onContextMenu=${(e) => { e.preventDefault(); onCancel(); }}>
|
onContextMenu=${(e) => { e.preventDefault(); onCancel(); }}>
|
||||||
${g ? html`<${Stripes} tiles=${tiles} view=${view} row=${row} col=${col} />` : null}
|
${g ? html`<${Stripes} tiles=${tiles} view=${view} row=${row} col=${col} />` : null}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export { terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits } from './gameLog
|
|||||||
export { normalizeDraft, normalizeSp } from './gameLogic/draft.js';
|
export { normalizeDraft, normalizeSp } from './gameLogic/draft.js';
|
||||||
export { groupEnemies, PEN, penZoneTiles, penPlacement, previewEnemyKey, factionTypes } from './gameLogic/enemies.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 { 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 { DEFAULT_SETTINGS, VOICE_LANGS, sanitizeSettings } from './gameLogic/settings.js';
|
||||||
export { normalizeResult } from './gameLogic/result.js';
|
export { normalizeResult } from './gameLogic/result.js';
|
||||||
export { chessLoadout, unitLoadout } from './gameLogic/loadout.js';
|
export { chessLoadout, unitLoadout } from './gameLogic/loadout.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,
|
* 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
|
* 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
|
* 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
|
* own activation. Enter still activates buttons (it cannot be a shortcut; behind the facing wheel only its ✕:
|
||||||
* text field has the focus.
|
* 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 {{ key?: string, code?: string, ctrlKey?: boolean, metaKey?: boolean, altKey?: boolean, repeat?: boolean, target?: any }} e
|
||||||
* @param {any} [keys] the player's map (settings `keys`)
|
* @param {any} [keys] the player's map (settings `keys`)
|
||||||
* @returns {'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready'|'escape'|null}
|
* @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;
|
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,
|
* 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.
|
* right-click or long press — or a battle / teammate unit). Shop, reward, bond-member and intel (enemy) cards stay.
|
||||||
|
|||||||
@@ -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, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,7 +11,7 @@ import path from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import {
|
import {
|
||||||
HOTKEY_ACTIONS, DEFAULT_HOTKEYS, isBindableCode, hotkeyLabel, sanitizeHotkeys, rebindHotkey, isDefaultHotkeys, hotkeyOf,
|
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';
|
} from '../../public/js/ui/gameLogic.js';
|
||||||
|
|
||||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
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: ' ', code: 'Space' }, { ...DEFAULT_HOTKEYS, ready: 'KeyE' }), true, 'Space always: a focused button must not activate');
|
||||||
assert.equal(facingSwallows({ key: 'z', code: 'KeyZ' }), false);
|
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, /<div ref=\$\{rootRef\} tabIndex="-1" class=\$\{cx\('fwheel'/);
|
||||||
|
assert.match(src, /rootRef\.current\?\.focus\(\{ preventScroll: true \}\)/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('capturing a new key (the settings wait for a key)', () => {
|
describe('capturing a new key (the settings wait for a key)', () => {
|
||||||
|
|||||||
Reference in new issue
Block a user