diff --git a/CHANGELOG.md b/CHANGELOG.md index 730a228..eee90f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,13 @@ workflow copies it into the GitHub release notes. mid-drag started a pan underneath the active drag, shifting the view out from under it. One gesture at a time now, and releases always reach the editor. +- Clicking any button or checkbox left keyboard focus stuck on that control, + browser-style: after toggling a layer checkbox, Space toggled the checkbox + again instead of panning, arrow keys walked the panel, and editor + shortcuts (tool switching, entity editor) went dead until you clicked the + canvas. Clicked controls now hand the keyboard straight back to the + editor. Text fields, dropdowns, and sliders still keep focus, and tabbing + to a control deliberately still works. ## [1.4.0] - 2026-07-28 diff --git a/src/App.tsx b/src/App.tsx index 8e7709b..37fb964 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import { PrefabPlaceTool } from './tools/prefabPlaceTool'; import type { PrefabData } from './prefab/prefabTypes'; import { Camera } from './rendering/camera'; import { useToolLifecycle } from './hooks/useToolLifecycle'; +import { useChromeFocusGuard } from './hooks/useChromeFocusGuard'; import { useShipMeta } from './hooks/useShipMeta'; import { EditorCanvas } from './components/EditorCanvas'; import { Toolbar } from './components/Toolbar'; @@ -238,6 +239,10 @@ export const App: React.FC = () => { // (drag anchors, ghost previews, pickers, uncommitted strokes). useToolLifecycle(activeTool); + // Clicked buttons/checkboxes release the keyboard back to the editor + // instead of keeping browser focus and eating shortcuts. + useChromeFocusGuard(); + // What the fork intends the open file to be (#3): vessel / gameMap / POI / // salvage badges in the menu bar. Reading the ref here is safe because // forkDir only ever changes together with forkProvider, which is state. diff --git a/src/hooks/__tests__/useChromeFocusGuard.test.tsx b/src/hooks/__tests__/useChromeFocusGuard.test.tsx new file mode 100644 index 0000000..f35ffc1 --- /dev/null +++ b/src/hooks/__tests__/useChromeFocusGuard.test.tsx @@ -0,0 +1,127 @@ +import { describe, it, expect } from 'vitest'; +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { useChromeFocusGuard, isClickActivatedChrome } from '../useChromeFocusGuard'; + +/** + * The browser leaves keyboard focus on a clicked control; a desktop app does + * not. The guard blurs click-activated chrome after the click so shortcuts + * keep working (the field report: toggle a layer checkbox, then Space toggles + * the checkbox instead of panning and tool keybinds go dead). + * + * jsdom does not move focus on click the way a browser does, so each case + * focuses the control explicitly and then clicks: exactly the post-click + * state a real browser is left in. + */ + +function Harness() { + useChromeFocusGuard(); + return ( +
+ + + + +
+ ); +} + +describe('useChromeFocusGuard', () => { + it('releases focus from a clicked checkbox', () => { + const { getByTestId } = render(); + const checkbox = getByTestId('checkbox'); + checkbox.focus(); + fireEvent.click(checkbox); + expect(document.activeElement).not.toBe(checkbox); + }); + + it('releases focus from a clicked button', () => { + const { getByTestId } = render(); + const button = getByTestId('button'); + button.focus(); + fireEvent.click(button); + expect(document.activeElement).not.toBe(button); + }); + + it('leaves text inputs focused: typing there is the point', () => { + const { getByTestId } = render(); + const text = getByTestId('text'); + text.focus(); + fireEvent.click(text); + expect(document.activeElement).toBe(text); + }); + + it('leaves selects focused: blurring one closes its dropdown', () => { + const { getByTestId } = render(); + const select = getByTestId('select'); + select.focus(); + fireEvent.click(select); + expect(document.activeElement).toBe(select); + }); + + it('lets a click handler that focuses something else win', () => { + // The search bar's clear button refocuses the search input during its + // click handler; the guard must not blur the input it moved focus to. + function Refocus() { + useChromeFocusGuard(); + const inputRef = React.useRef(null); + return ( +
+ + +
+ ); + } + const { getByTestId } = render(); + const clear = getByTestId('clear'); + clear.focus(); + fireEvent.click(clear); + expect(document.activeElement).toBe(getByTestId('search')); + }); + + it('stops listening after unmount', () => { + const { unmount } = render(); + unmount(); + // With the guard gone, a clicked control keeps focus again. + const straggler = document.createElement('button'); + document.body.appendChild(straggler); + straggler.focus(); + fireEvent.click(straggler); + expect(document.activeElement).toBe(straggler); + straggler.remove(); + }); +}); + +describe('isClickActivatedChrome', () => { + it.each([ + ['checkbox', true], + ['radio', true], + ['button', true], + ['submit', true], + ['file', true], + ['text', false], + ['number', false], + ['search', false], + ['range', false], + ['color', false], + ])('input[type=%s] -> %s', (type, expected) => { + const input = document.createElement('input'); + input.type = type as string; + expect(isClickActivatedChrome(input)).toBe(expected); + }); + + it('classifies buttons and links as chrome, selects and textareas not', () => { + expect(isClickActivatedChrome(document.createElement('button'))).toBe(true); + expect(isClickActivatedChrome(document.createElement('a'))).toBe(true); + expect(isClickActivatedChrome(document.createElement('select'))).toBe(false); + expect(isClickActivatedChrome(document.createElement('textarea'))).toBe(false); + expect(isClickActivatedChrome(null)).toBe(false); + }); +}); diff --git a/src/hooks/useChromeFocusGuard.ts b/src/hooks/useChromeFocusGuard.ts new file mode 100644 index 0000000..42a9cf8 --- /dev/null +++ b/src/hooks/useChromeFocusGuard.ts @@ -0,0 +1,45 @@ +import { useEffect } from 'react'; + +/** + * Desktop apps don't leave keyboard focus on a control after a mouse click: + * clicking a toolbar button or a panel checkbox acts once, and the keyboard + * still belongs to the document. The browser instead moves focus to the + * clicked control and leaves it there, so after toggling a layer checkbox, + * Space re-toggles it (instead of panning), Tab walks the panel, and every + * editor shortcut dies on the useKeyboard input guard. + * + * This guard restores the desktop rule app-wide: after a click lands on + * click-activated chrome (buttons, links, checkboxes, radios), focus is + * released back to the document. Controls where post-click keyboard input is + * the point (text fields, selects, sliders, color wells) keep focus, and + * keyboard-driven focus (tabbing to a control deliberately) is untouched + * because the guard only reacts to clicks. + */ + +/** Input types that act on click and have no post-click keyboard role. */ +const CLICK_ACTIVATED_INPUT_TYPES = new Set(['checkbox', 'radio', 'button', 'submit', 'reset', 'file']); + +/** Exported for testing. */ +export function isClickActivatedChrome(el: Element | null): el is HTMLElement { + if (!el) return false; + const tag = el.tagName; + if (tag === 'BUTTON' || tag === 'A') return true; + if (tag === 'INPUT') return CLICK_ACTIVATED_INPUT_TYPES.has((el as HTMLInputElement).type); + return false; +} + +export function useChromeFocusGuard(): void { + useEffect(() => { + // Document-level bubble listener: React's root-container handlers run + // first (the click does its job), then focus is released. A handler that + // deliberately focuses something else during the click (e.g. the search + // bar's clear button refocusing its text input) wins, because the guard + // checks what is focused NOW, not what was clicked. + const onClick = () => { + const el = document.activeElement; + if (isClickActivatedChrome(el)) el.blur(); + }; + document.addEventListener('click', onClick); + return () => document.removeEventListener('click', onClick); + }, []); +}