Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down
127 changes: 127 additions & 0 deletions src/hooks/__tests__/useChromeFocusGuard.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<label>
Layer
<input type="checkbox" data-testid="checkbox" />
</label>
<button data-testid="button">Tool</button>
<input type="text" data-testid="text" />
<select data-testid="select">
<option>one</option>
</select>
</div>
);
}

describe('useChromeFocusGuard', () => {
it('releases focus from a clicked checkbox', () => {
const { getByTestId } = render(<Harness />);
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(<Harness />);
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(<Harness />);
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(<Harness />);
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<HTMLInputElement>(null);
return (
<div>
<button data-testid="clear" onClick={() => inputRef.current?.focus()}>
Clear
</button>
<input type="text" data-testid="search" ref={inputRef} />
</div>
);
}
const { getByTestId } = render(<Refocus />);
const clear = getByTestId('clear');
clear.focus();
fireEvent.click(clear);
expect(document.activeElement).toBe(getByTestId('search'));
});

it('stops listening after unmount', () => {
const { unmount } = render(<Harness />);
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);
});
});
45 changes: 45 additions & 0 deletions src/hooks/useChromeFocusGuard.ts
Original file line number Diff line number Diff line change
@@ -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);
}, []);
}
Loading