mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 08:20:50 +00:00
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:
@@ -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>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user