Files
noitu/web/src/lib/components/WordInput.svelte
T
tiennm99 9be86a3cad refactor(web): share alert banner and turn actions, fix restyle leaks
Add AlertBanner and turnActions to replace duplicated markup and
wrappers, and share component-test setup.

Fixes: oversized dismiss, help, rules and skip-link text after the type
ramp collapse; report button hover styled as submit; chat draft lost
when the panel folds; resign/claim/kick styles leaking app-wide.
2026-09-28 15:19:59 +07:00

385 lines
13 KiB
Svelte

<script>
import { fill, t } from '$lib/i18n/vi.js';
import { RejectReason } from '$lib/proto/noitu/v1/game_pb.js';
import { game } from '$lib/stores/game.svelte.js';
import { Status, connection } from '$lib/ws/connection.svelte.js';
/**
* @type {{
* onsubmit: (word: string) => boolean,
* onreportword: (word: string) => void
* }}
*/
let { onsubmit, onreportword } = $props();
/** @type {HTMLInputElement | undefined} */
let field = $state();
let composing = $state(false);
// The connection is part of the condition, not just the turn. A word typed
// during a reconnect would be dropped by a closed socket and the player
// would watch their turn expire with no idea why.
const enabled = $derived(
game.state.phase === 'playing' && game.state.myTurn && connection.status === Status.OPEN
);
// Why the field cannot be sent from, in its own placeholder: an inviting
// "Nhập từ của bạn" over a dead input is how a player ends up typing into
// one. Kept short — the board's turn line above says who is thinking, and
// a sentence here is cut off by the send button beside it.
const waitingFor = $derived.by(() => {
if (enabled) return t.wordInputPlaceholder;
if (connection.status !== Status.OPEN) return t.wordInputOffline;
return t.wordInputWaiting;
});
// What the field was last seeded for. Plain lets, not state: they guard the
// effect below and must not re-trigger it. The rejection is part of the key
// because a refused word empties the field without ending the turn, and the
// player deserves the seed back before they retype.
let seededTurn = -1;
/** @type {unknown} */
let seededRejection = null;
/**
* Whether the player is typing somewhere else — the chat, in practice,
* which now sits beside the board rather than folded away under it.
*
* Taking focus off a field somebody is mid-sentence in would drop the rest
* of that sentence into the word field, so a turn arriving is allowed to
* ask for focus only when nothing else holds it.
*/
function typingElsewhere() {
const active = document.activeElement;
if (!active || active === field) return false;
return (
active instanceof HTMLElement &&
(active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)
);
}
/**
* What the field held when the turn passed, so anything typed into it out
* of turn can be put back. A plain let: nothing renders it, and making it
* state would re-run the effects below on every keystroke it absorbs.
*/
let lockedValue = '';
// Captured on the way out of the turn, not on every keystroke: reading the
// field here is what makes `enabled` the only thing that moves it.
$effect(() => {
if (enabled || !field) return;
lockedValue = field.value;
});
/**
* Out of turn the field takes no text. It stays focusable and focused —
* that is what keeps the on-screen keyboard alive between turns, and is
* why this is not `disabled` or `readonly` — but a keystroke, a paste and
* a drop all do nothing, so nobody spends their opponent's turn typing a
* word that was never going to be sent.
* @param {Event} event
*/
function guardInput(event) {
if (!enabled) event.preventDefault();
}
/**
* The fallback for text the guard cannot refuse: `beforeinput` is not
* cancelable for a composition, which is how every Vietnamese input method
* types. The composed character lands and is taken straight back out.
*/
function undoInput() {
if (enabled || !field || field.value === lockedValue) return;
field.value = lockedValue;
}
// Focus when the turn arrives, so a player on a phone can type without
// reaching for the field, and seed it with the syllable the word has to
// start with — that part of the answer is already decided, and typing it
// again is the one keystroke sequence every single turn shares.
//
// Gated on `enabled` rather than just the turn and the phase: seeding
// during a reconnect wrote into a field the player could not submit from,
// and the first composition event then undid the seed anyway (undoInput
// yanks back anything typed while offline), making it non-deterministic
// exactly when the player is anxious about a running clock.
$effect(() => {
if (!enabled) return;
const turn = game.state.turnSeq;
const syllable = game.state.currentSyllable;
const rejection = game.state.rejection;
// The seeding below happens either way: it writes into the field
// without disturbing wherever the player actually is.
if (!typingElsewhere()) field?.focus();
if (!field || (turn === seededTurn && rejection === seededRejection)) return;
seededTurn = turn;
seededRejection = rejection;
if (!syllable) return;
// The field keeps whatever it held, so a draft the player never sent may
// now be aimed at a syllable the game has moved past. One that still
// starts with what is being asked for is the player's word and is left
// alone; one that does not is worse than no draft at all.
const draft = field.value.trim();
if (draft && draft.toLowerCase().startsWith(syllable.toLowerCase())) return;
field.value = `${syllable} `;
caretToEnd(field);
});
/**
* Puts the caret after whatever the field holds, so typing continues the
* word instead of landing in front of it.
* @param {HTMLInputElement} input
*/
function caretToEnd(input) {
input.setSelectionRange(input.value.length, input.value.length);
}
/** @param {SubmitEvent} event */
function handleSubmit(event) {
event.preventDefault();
// Enter can commit an IME candidate rather than the form. Submitting
// mid-composition would send a half-typed word and swallow the
// keystroke that was choosing the accent.
if (composing || !enabled || !field) return;
const word = field.value.trim();
if (!word) return;
// Only clear a word that actually went out. Clearing regardless would
// erase what the player typed and leave nothing to resend.
if (!onsubmit(word)) return;
// Safe here and only here: composition has ended by the time a submit
// is delivered, so clearing cannot destroy an accent being formed.
field.value = '';
// Still inside the gesture that submitted, which is the only context an
// on-screen keyboard will reopen for. Tapping "Gửi" moves focus to the
// button, and a focus() from the effect above arrives on a WebSocket
// message instead — iOS ignores that one and the player pays a dead tap
// every turn to get the keyboard back.
field.focus();
}
/**
* Fills the field with the server's suggestion and focuses it. A direct
* write, unlike everywhere else in this field: it happens only from this
* click, a gesture the player just made rather than one mid-keystroke, so
* no IME composition can be in progress for it to cancel.
*/
function useSuggestion() {
const suggestion = game.state.rejection?.suggestion;
if (!suggestion || !field) return;
field.value = suggestion;
field.focus();
caretToEnd(field);
}
function report() {
const word = game.state.rejection?.word;
if (word) onreportword(word);
}
</script>
<form class="input-row" onsubmit={handleSubmit}>
<!--
Deliberately uncontrolled. Vietnamese diacritics are typed over several
keystrokes by a Telex or VNI input method, and writing the value back on
every keystroke cancels that composition and mangles the accent. The
server normalizes the text anyway, so the client has no reason to touch
it.
Never `disabled`, and never `readonly` either: setting either on the
focused field costs the on-screen keyboard — `disabled` blurs it
outright — and nothing can reopen that without a tap, so a phone player
pays a dead tap every turn. Out of turn the field refuses text instead,
in guardInput, and the submit button and handleSubmit refuse the word.
The accessible name stays put while the placeholder changes, so the
field is still the same field to anybody listening.
-->
<input
bind:this={field}
type="text"
name="word"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
enterkeyhint="send"
aria-disabled={!enabled}
aria-invalid={!!game.state.rejection}
aria-describedby={game.state.rejection ? 'word-rejection' : undefined}
placeholder={waitingFor}
aria-label={t.wordInputPlaceholder}
oncompositionstart={() => (composing = true)}
oncompositionend={() => (composing = false)}
onbeforeinput={guardInput}
oninput={undoInput}
/>
<!-- Named, because the chat's send button says the same word: both are a
"Gửi", and only a test can tell them apart by where they are. -->
<button type="submit" disabled={!enabled} data-testid="word-submit">{t.submit}</button>
</form>
{#if game.state.rejection}
<!-- The word is shown, not only the verdict. A Vietnamese rejection is
usually one tone mark out, and the field was cleared when the word went
out — so without this the player rebuilds it from memory, under the
clock, with nothing to compare against. -->
<p class="rejection" id="word-rejection" role="alert">
<strong>{game.state.rejection.word}</strong> — {game.state.rejection.message}
</p>
{#if game.state.rejection.suggestion || game.state.rejection.reason === RejectReason.NOT_IN_DICTIONARY}
<!-- Their own line, at a real tap size: under the clock these are the
two most valuable taps on the board, and the pills they used to be
were under WCAG's 24px minimum. -->
<div class="fixes">
{#if game.state.rejection.suggestion}
<!-- Corrects typing, not vocabulary: the server only ever offers
this for a word that differs from a real one by diacritics
alone — primary weight because it is almost certainly the
word the player meant. -->
<button type="button" class="fix suggestion" onclick={useSuggestion}>
{fill(t.suggestionPrompt, { word: game.state.rejection.suggestion })}
</button>
{/if}
{#if game.state.rejection.reason === RejectReason.NOT_IN_DICTIONARY}
<button type="button" class="fix report" onclick={report}>{t.reportWord}</button>
{/if}
</div>
{/if}
{/if}
{#if game.state.reportConfirmation}
<p class="report-confirmation" role="status">
{game.state.reportConfirmation}
</p>
{/if}
<style>
.input-row {
display: flex;
gap: var(--space-2);
}
input {
flex: 1;
min-width: 0;
padding: var(--space-4);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--surface);
/* 16px or larger stops iOS Safari zooming the page on focus, which on a
phone hides half the board behind the keyboard. */
font-size: var(--text-3);
/* Whatever does not fit ends in an ellipsis rather than against the
edge of the box. */
text-overflow: ellipsis;
}
/* Looks exactly as the disabled field used to. It is only the behaviour
that differs: focus, and therefore the keyboard, survives the turn — so
the caret has to go by hand, or a field that takes no text sits there
blinking as though it were waiting for some. */
input[aria-disabled='true'] {
caret-color: transparent;
cursor: default;
background: var(--surface-alt);
color: var(--text-muted);
}
/* Scoped to the row: a bare `button` rule also reached the fix buttons
below, and its hover and press states — more specific than
`.fix:hover` — turned the report button solid accent under dark text. */
.input-row button {
padding: var(--space-4) var(--space-5);
border: 0;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--accent-text);
font-weight: 600;
transition: background-color 150ms ease-out;
}
.input-row button:hover:not(:disabled) {
background: var(--accent-hover);
}
.input-row button:active:not(:disabled) {
background: var(--accent-pressed);
}
.input-row button:disabled {
background: var(--surface-alt);
color: var(--text-muted);
}
.rejection {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px var(--space-2);
margin: var(--space-2) 0 0;
padding: var(--space-3) var(--space-3);
border-radius: var(--radius-sm);
background: var(--danger-soft);
color: var(--danger);
font-size: var(--text-2);
}
.rejection strong {
font-weight: 600;
}
.fixes {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-top: var(--space-2);
}
.fix {
min-height: 36px;
padding: var(--space-1) var(--space-3);
border: 1px solid var(--border-strong);
border-radius: var(--radius-pill);
background: var(--surface);
color: var(--text);
font-size: var(--text-2);
font-weight: 600;
text-align: left;
transition: background-color 150ms ease-out;
}
.fix:hover {
background: var(--surface-alt);
}
/* Primary weight: of the two, this is the one tap that almost certainly
finishes the turn. */
.fix.suggestion {
border-color: transparent;
background: var(--accent);
color: var(--accent-text);
}
.fix.suggestion:hover {
background: var(--accent-hover);
}
.fix.suggestion:active {
background: var(--accent-pressed);
}
.report-confirmation {
margin: var(--space-2) 0 0;
padding: var(--space-3) var(--space-3);
border-radius: var(--radius-sm);
background: var(--accent-soft);
color: var(--text);
font-size: var(--text-2);
}
</style>