Fix per-frame layout jank when focusing a toggle-input-card field (#4314)

## Problem

Focusing the number field of a `toggle-input-card` (Game Timer / Gold
Multiplier / Starting Gold, in both the single-player and host-lobby
modals) cost several ms of layout/paint **every tick** for as long as
the field stayed focused.

## Root cause

The input was rendered **conditionally** — `${this.checked ?
html`…<input>…` : nothing}`. Enabling a toggle therefore **freshly
inserts** the `<input>` into the DOM, and **focusing a just-inserted
input** is what forced the per-frame layout/paint. An input that was
already present in the DOM doesn't do this.

## Fix

Keep the input **permanently mounted** and toggle a `hidden` class when
unchecked, instead of conditionally rendering it. Focusing it is then
always focusing an element that was already there. Because both modals
share `<toggle-input-card>`, this single change fixes both.

Also restores the **autofocus + select** of the field on enable (it had
been removed earlier while chasing this bug) — safe now that the input
isn't freshly inserted.

No other UX change: the toggle behavior, checkmark, styling, and all
three cards behave identically.

## Testing

Hard-reload, then in both the Solo and Host-lobby modals, enable each of
Game Timer / Gold Multiplier / Starting Gold, type a value, and keep the
field focused — smooth, no per-frame jank, and the field autofocuses on
enable.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-06-17 08:15:24 -07:00
committed by GitHub
parent bb464538d0
commit 678112492c
+39 -23
View File
@@ -1,4 +1,4 @@
import { LitElement, html, nothing } from "lit"; import { LitElement, PropertyValues, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import { translateText } from "../Utils"; import { translateText } from "../Utils";
import { CARD_LABEL_CLASS, INPUT_CLASS, cardClass } from "./InputCardStyles"; import { CARD_LABEL_CLASS, INPUT_CLASS, cardClass } from "./InputCardStyles";
@@ -28,6 +28,18 @@ export class ToggleInputCard extends LitElement {
createRenderRoot() { createRenderRoot() {
return this; return this;
} }
// Autofocus + select the number input when the card is toggled on. Safe now
// that the input is always mounted (focusing a freshly-inserted one janked).
protected updated(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("checked")) return;
if (changedProperties.get("checked") === false && this.checked) {
const input = this.querySelector("input");
input?.focus();
input?.select();
}
}
private toOptionalNumber( private toOptionalNumber(
value: number | string | undefined, value: number | string | undefined,
): number | undefined { ): number | undefined {
@@ -120,28 +132,32 @@ export class ToggleInputCard extends LitElement {
</span> </span>
</button> </button>
${this.checked <!-- Keep the input permanently mounted and just hide it when unchecked.
? html` Rendering it conditionally (\${checked ? input : nothing}) inserts a
<div fresh input on enable, and focusing a just-inserted input forces
class="absolute left-3 right-3 top-1/2 -translate-y-1/2 z-10" several ms of layout/paint per frame. CSS-hiding an always-present
> input avoids that. -->
<input <div
type=${this.inputType} class="absolute left-3 right-3 top-1/2 -translate-y-1/2 z-10 ${this
id=${this.inputId ?? nothing} .checked
min=${this.inputMin ?? nothing} ? ""
max=${this.inputMax ?? nothing} : "hidden"}"
step=${this.inputStep ?? nothing} >
.value=${String(this.inputValue ?? "")} <input
class=${INPUT_CLASS} type=${this.inputType}
aria-label=${this.inputAriaLabel ?? nothing} id=${this.inputId ?? nothing}
placeholder=${this.inputPlaceholder ?? nothing} min=${this.inputMin ?? nothing}
@input=${this.onInput} max=${this.inputMax ?? nothing}
@change=${this.onChange} step=${this.inputStep ?? nothing}
@keydown=${this.onKeyDown} .value=${String(this.inputValue ?? "")}
/> class=${INPUT_CLASS}
</div> aria-label=${this.inputAriaLabel ?? nothing}
` placeholder=${this.inputPlaceholder ?? nothing}
: nothing} @input=${this.onInput}
@change=${this.onChange}
@keydown=${this.onKeyDown}
/>
</div>
</div> </div>
`; `;
} }