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
6 changes: 5 additions & 1 deletion app/src/assets/design/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,11 @@ button { cursor: pointer; }

:focus { outline: none; }

:focus-visible {
/* The controller moves the focus by script, and after any mouse click Chromium stops drawing :focus-visible for that:
the D-pad then walked the keyboard with nothing lit. While the pad is in use (data-input, set by gamepad.ts) every
focus is drawn. A dialog that takes the focus only to be announced (tabindex -1) gets no ring. */
:focus-visible,
:root[data-input="pad"] :focus:not([tabindex="-1"]) {
outline: var(--focus-ring) solid var(--accent);
outline-offset: calc(2 * var(--u));
}
Expand Down
12 changes: 11 additions & 1 deletion app/src/renderer/shell/gamepad.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useRef } from "preact/hooks";
import { controllerFamily, type ControllerFamily } from "../../shared/controllerGlyphs";
import { Button, DOUBLE_PRESS_MS, directionOf, stickToScroll, stickToVelocity, type Direction } from "../../shared/controllerInput";
import { Button, DOUBLE_PRESS_MS, STICK_DEADZONE, directionOf, stickToScroll, stickToVelocity, type Direction } from "../../shared/controllerInput";
import { activePad, padsInUse, startedUsing, type PadSample } from "../../shared/controllerSelection";
import { GAMEPAD_POLL_MS, gamepadPoll, STICK_INTERVAL_MS } from "../../shared/gamepadLoop";
import type { ShellState } from "../../shared/ipc";
Expand Down Expand Up @@ -106,6 +106,10 @@ export function useGamepad(state: ShellState, overlay: Overlay, setOverlay: (o:
bridge().controller({ type: "connected", connected: true, family: family.current });
}

// The pad is what is driving: its focus moves must be drawn (tokens.css), whatever the mouse did before.
if (pad.buttons.some((b) => b.pressed) || pad.axes.some((axis) => Math.abs(axis) > STICK_DEADZONE)) {
document.documentElement.dataset.input = "pad";
}
const s = stateRef.current;
const openOverlay = overlayRef.current;
const overlayOpen = openOverlay !== "none" || Boolean(s.keyboard) || Boolean(s.permission);
Expand Down Expand Up @@ -217,6 +221,10 @@ export function useGamepad(state: ShellState, overlay: Overlay, setOverlay: (o:
}
// Up from the top of the page: the main process hands the focus over and the address field takes it.
bridge().onFocusChrome(() => document.getElementById("address")?.focus());
// A click or a key on a real keyboard hands focus drawing back to the browser's own rules.
const notPad = (): void => { delete document.documentElement.dataset.input; };
addEventListener("pointerdown", notPad, true);
addEventListener("keydown", notPad, true);
resume();
const visibility = (): void => {
if (!document.hidden) resume();
Expand All @@ -228,6 +236,8 @@ export function useGamepad(state: ShellState, overlay: Overlay, setOverlay: (o:
});
return () => {
stop();
removeEventListener("pointerdown", notPad, true);
removeEventListener("keydown", notPad, true);
document.removeEventListener("visibilitychange", visibility);
stopActivity();
};
Expand Down
36 changes: 25 additions & 11 deletions app/src/renderer/shell/shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ body {
}

.strip-btn:hover,
.strip-btn:focus-visible { background: var(--hover); color: var(--text); }
.strip-btn:focus-visible,
:root[data-input="pad"] .strip-btn:focus { background: var(--hover); color: var(--text); }

.strip-btn .badge {
position: absolute;
Expand Down Expand Up @@ -177,12 +178,17 @@ body {
}

.tab-x:hover,
.tab-x:focus-visible { background: var(--hover); color: var(--text); }
.tab-x:focus-visible,
:root[data-input="pad"] .tab-x:focus { background: var(--hover); color: var(--text); }

.tab-main:focus-visible,
.tab-x:focus-visible,
.new-tab:focus-visible,
.strip-btn:focus-visible { outline-offset: calc(-2 * var(--u)); }
.strip-btn:focus-visible,
:root[data-input="pad"] .tab-main:focus,
:root[data-input="pad"] .tab-x:focus,
:root[data-input="pad"] .new-tab:focus,
:root[data-input="pad"] .strip-btn:focus { outline-offset: calc(-2 * var(--u)); }

.new-tab {
flex: none;
Expand Down Expand Up @@ -217,7 +223,8 @@ body {
.window-btn:active { background: var(--border); }
.window-btn.close:hover { background: #c42b1c; color: #fff; }
.window-btn.close:active { background: #c42b1ccc; color: #fff; }
.window-btn:focus-visible { outline-offset: calc(-3 * var(--u)); }
.window-btn:focus-visible,
:root[data-input="pad"] .window-btn:focus { outline-offset: calc(-3 * var(--u)); }

/* ---- Toolbar ---- */

Expand All @@ -244,7 +251,8 @@ body {
}

.icon-btn:hover,
.icon-btn:focus-visible { background: var(--hover); }
.icon-btn:focus-visible,
:root[data-input="pad"] .icon-btn:focus { background: var(--hover); }
.icon-btn.on { color: var(--accent); }

.address {
Expand Down Expand Up @@ -288,7 +296,8 @@ body {
}

.address-btn:hover,
.address-btn:focus-visible { background: var(--hover); color: var(--text); }
.address-btn:focus-visible,
:root[data-input="pad"] .address-btn:focus { background: var(--hover); color: var(--text); }
.address-btn.on { color: var(--accent); }

.tab-hint {
Expand Down Expand Up @@ -389,7 +398,8 @@ body {
}

.menu-item:hover,
.menu-item:focus-visible { background: var(--hover); }
.menu-item:focus-visible,
:root[data-input="pad"] .menu-item:focus { background: var(--hover); }

/* ---- Tab switcher ---- */

Expand Down Expand Up @@ -498,8 +508,10 @@ body {
text-align: left;
}

.chip:focus-visible { background: var(--text); color: var(--bg); outline: none; }
.chip:focus-visible .chip-url { color: inherit; }
.chip:focus-visible,
:root[data-input="pad"] .chip:focus { background: var(--text); color: var(--bg); outline: none; }
.chip:focus-visible .chip-url,
:root[data-input="pad"] .chip:focus .chip-url { color: inherit; }
.chip-title { font-size: var(--text-sm); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chip-url { font-size: var(--text-xs); color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

Expand Down Expand Up @@ -528,7 +540,8 @@ body {

.key:hover { background: var(--hover); }
/* The selected key turns white, as on Xbox: the one thing on the panel the eye finds from the sofa. */
.key:focus-visible {
.key:focus-visible,
:root[data-input="pad"] .key:focus {
z-index: 1;
background: var(--text);
color: var(--bg);
Expand All @@ -538,7 +551,8 @@ body {

.key.on { background: var(--accent); color: var(--accent-text); }
.key.primary { background: var(--accent); color: var(--accent-text); }
.key.primary:focus-visible { background: var(--text); color: var(--bg); }
.key.primary:focus-visible,
:root[data-input="pad"] .key.primary:focus { background: var(--text); color: var(--bg); }
.key-label { font-size: inherit; line-height: 1; }
.key-gap { pointer-events: none; }

Expand Down
15 changes: 15 additions & 0 deletions app/tests/e2e/controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,3 +373,18 @@ test("the pointer is on every page from the start, and stays where it was when t
await expect.poll(pointerShown, { timeout: 10_000 }).toBe(true);
await expect.poll(where).toBe(first);
});

test("after a mouse click, the key the D-pad reaches is still lit", async () => {
await installPads([XBOX]);
// A mouse click first: from then on Chromium stops drawing :focus-visible for focus moved by script.
await chrome.locator(".new-tab").click();
await expect(chrome.locator("#keyboard")).toBeVisible();
await step(Pad.Right);
await expect.poll(shellFocus).toBe("q");
// The selected key turns the text colour (white on the dark themes), not the grey of the other keys.
const lit = () => chrome.evaluate(() => getComputedStyle(document.activeElement as Element).backgroundColor === getComputedStyle(document.body).color);
await expect.poll(lit).toBe(true);
await step(Pad.Right);
await expect.poll(shellFocus).toBe("w");
await expect.poll(lit).toBe(true);
});
Loading