refactor(web): extract ArmedButton for the three press-twice controls

Resign, claim-dead-end and kick each duplicated the same arm/disarm timer
and disarm-on-disable effect. ArmedButton.svelte owns that once, plus the
a11y gap none of the three closed: aria-pressed carries the armed state to
assistive tech, since a screen reader announces a control's name on focus,
not on the in-place label swap the first press used to be silent about.

Kick arms per seat now rather than sharing one Lobby-level slot, which was
an implementation detail of the old shared state rather than a stated rule.
This commit is contained in:
tiennm99 committed 2026-09-21 16:21:40 +07:00
1 parent f0c2334228
commit f0bcb6084a
3 files changed
+121 -118

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
<script>
/**
* A press-twice control, in place of a native confirm(): resigning,
* claiming a dead end and kicking a player all need "are you sure"
* without one, because confirm() blocks the main thread and the
* countdown ring's frame loop keeps running underneath it — hesitating
* over the dialog can cost the very turn the confirmation was meant to
* protect.
*
* The second press is the same control asking again, not a different
* one, so this owns the arm timer and disarms itself the moment
* `disabled` goes true — an armed button that loses the offer it was
* making (the turn moves on, the seat becomes unkickable) must not sit
* there waiting for a press that would now mean something else.
*
* `aria-pressed` carries the armed state to assistive tech. A screen
* reader announces a control's accessible name on focus, not on an
* in-place mutation of it, so a swapped label alone is silent to a
* non-sighted player on the first press; the pressed-state change on the
* element they already have focus on is what gets spoken.
* @type {{
* label: string,
* confirmLabel: string,
* onconfirm: () => void,
* disabled?: boolean,
* armMs?: number,
* class?: string,
* testid?: string,
* children?: import('svelte').Snippet
* }}
*/
let {
label,
confirmLabel,
onconfirm,
disabled = false,
armMs = 4000,
class: className = '',
testid,
children
} = $props();
let armed = $state(false);
/** @type {ReturnType<typeof setTimeout>} */
let timer;
$effect(() => {
if (disabled) {
clearTimeout(timer);
armed = false;
}
});
$effect(() => () => clearTimeout(timer));
function press() {
if (armed) {
clearTimeout(timer);
armed = false;
onconfirm();
return;
}
armed = true;
clearTimeout(timer);
timer = setTimeout(() => (armed = false), armMs);
}
</script>
<button
type="button"
class={className}
class:arming={armed}
{disabled}
aria-pressed={armed}
aria-label={armed ? confirmLabel : label}
data-testid={testid}
onclick={press}
>
{#if children}
{@render children()}
{:else}
{armed ? confirmLabel : label}
{/if}
</button>
+23 -85
View File
@@ -1,4 +1,5 @@
<script>
import ArmedButton from '$lib/components/ArmedButton.svelte';
import ChainHistory from '$lib/components/ChainHistory.svelte';
import ConnectionBadge from '$lib/components/ConnectionBadge.svelte';
import CountdownRing from '$lib/components/CountdownRing.svelte';
@@ -42,17 +43,6 @@
onchatopen
} = $props();
/** How long an armed resign or claim button waits before it goes back to being safe. */
const ARM_MS = 4000;
let arming = $state(false);
/** @type {ReturnType<typeof setTimeout>} */
let armTimer;
let claimArming = $state(false);
/** @type {ReturnType<typeof setTimeout>} */
let claimArmTimer;
// Whose turn it is, said by name. With four people at the table "the
// opponent is thinking" stops naming anybody.
const turnLabel = $derived.by(() => {
@@ -68,61 +58,9 @@
// is to leave the room, which the lobby's own button does.
const canResign = $derived(game.state.myTurn && !offline);
// An armed button that loses the turn goes back to being safe: the second
// press would arrive at a button that is no longer the one the player was
// looking at.
$effect(() => {
if (canResign) return;
clearTimeout(armTimer);
arming = false;
});
// A claim is the same offer resign is: made only on the player's own turn,
// on the same reasoning canResign already states.
const canClaimDeadEnd = $derived(game.state.myTurn && !offline);
$effect(() => {
if (canClaimDeadEnd) return;
clearTimeout(claimArmTimer);
claimArming = false;
});
/**
* Two presses, in place of a native confirm().
*
* confirm() blocks the main thread, which stops the countdown's animation
* frame loop while the server's deadline keeps running: hesitating over the
* dialog can cost the turn it was protecting. This keeps the board on screen
* and the clock moving, and disarms itself so a stray tap does not lie in
* wait.
*/
function armOrResign() {
if (arming) {
clearTimeout(armTimer);
arming = false;
onresign();
return;
}
arming = true;
clearTimeout(armTimer);
armTimer = setTimeout(() => (arming = false), ARM_MS);
}
/** Same two-press shape as armOrResign, and for the same reason. */
function armOrClaim() {
if (claimArming) {
clearTimeout(claimArmTimer);
claimArming = false;
onclaimdeadend();
return;
}
claimArming = true;
clearTimeout(claimArmTimer);
claimArmTimer = setTimeout(() => (claimArming = false), ARM_MS);
}
$effect(() => () => clearTimeout(armTimer));
$effect(() => () => clearTimeout(claimArmTimer));
</script>
<section class="board" data-phase={game.state.phase}>
@@ -237,15 +175,13 @@
the current syllable, which only means something on this
player's own turn — unlike resign, there is no "not yet" state
worth showing for it off turn. -->
<button
type="button"
<ArmedButton
class="claim-dead-end"
class:arming={claimArming}
label={t.claimDeadEnd}
confirmLabel={t.claimDeadEndSure}
disabled={!canClaimDeadEnd}
onclick={armOrClaim}
>
{claimArming ? t.claimDeadEndSure : t.claimDeadEnd}
</button>
onconfirm={onclaimdeadend}
/>
{/if}
{#if game.state.claimError}
<p class="claim-error" role="alert">
@@ -265,15 +201,13 @@
that grows, and a button under it walks off the bottom of the screen
exactly as the game gets long enough to want to give up on. -->
{#if game.state.phase === 'playing' && !game.iAmOut}
<button
type="button"
<ArmedButton
class="resign"
class:arming
label={t.resign}
confirmLabel={t.resignSure}
disabled={!canResign}
onclick={armOrResign}
>
{arming ? t.resignSure : t.resign}
</button>
onconfirm={onresign}
/>
{/if}
<ChainHistory />
@@ -418,10 +352,14 @@
text-align: center;
}
/* :global(): these are ArmedButton's own <button>, not one this
component's template renders directly, so Svelte's scoped-style
attribute never lands on it. */
/* Right of the board and away from the input: giving up is the one thing
here nobody should hit by accident while typing. Danger coloured because
it ends the game, subordinate because it is not the way to play it. */
.resign {
:global(.resign) {
align-self: flex-end;
/* Below the 44px the rest of the controls keep, deliberately: this is
the one button here nobody is trying to hit, it takes two presses to
@@ -438,20 +376,20 @@
transition: background-color 150ms ease-out;
}
.resign:hover:enabled {
:global(.resign:hover:enabled) {
background: var(--danger-soft);
}
/* Off turn: still there, so the way out of the game does not appear and
disappear under the player's thumb every handover, but plainly not the
thing to press yet. */
.resign:disabled {
:global(.resign:disabled) {
border-color: var(--border);
color: var(--text-muted);
}
/* Armed, and saying so: the second press is the one that ends the game. */
.resign.arming {
:global(.resign.arming) {
border-color: var(--danger);
background: var(--danger-soft);
font-weight: 600;
@@ -461,7 +399,7 @@
the syllable on screen right now, so it reads as part of answering it
rather than as a way out of the game. Secondary weight either way — it
is not the way to play a turn, just a shortcut past an empty one. */
.claim-dead-end {
:global(.claim-dead-end) {
align-self: flex-start;
min-height: 32px;
padding: var(--space-1) var(--space-3);
@@ -473,16 +411,16 @@
transition: background-color 150ms ease-out;
}
.claim-dead-end:hover:enabled {
:global(.claim-dead-end:hover:enabled) {
background: var(--surface-alt);
}
.claim-dead-end:disabled {
:global(.claim-dead-end:disabled) {
border-color: var(--border);
color: var(--text-muted);
}
.claim-dead-end.arming {
:global(.claim-dead-end.arming) {
border-color: var(--accent);
background: var(--accent-soft);
font-weight: 600;
+14 -33
View File
@@ -1,4 +1,5 @@
<script>
import ArmedButton from '$lib/components/ArmedButton.svelte';
import ConnectionBadge from '$lib/components/ConnectionBadge.svelte';
import PlayerStatus from '$lib/components/PlayerStatus.svelte';
import RoomCodePanel from '$lib/components/RoomCodePanel.svelte';
@@ -33,9 +34,6 @@
*/
let { compact = false, actionHeld = false, onready, onstart, onkick, onleave } = $props();
/** How long an armed kick waits before it goes back to being safe. */
const ARM_MS = 4000;
const s = $derived(game.state);
// The seats nobody is in yet, drawn so a room that is waiting on people
// looks like one rather than like a room that is simply small.
@@ -55,25 +53,6 @@
})
);
/** The seat whose kick button is armed, if any. */
let armedKick = $state('');
/** @type {ReturnType<typeof setTimeout>} */
let armTimer;
/** @param {string} playerId */
function armOrKick(playerId) {
if (armedKick === playerId) {
clearTimeout(armTimer);
armedKick = '';
onkick(playerId);
return;
}
armedKick = playerId;
clearTimeout(armTimer);
armTimer = setTimeout(() => (armedKick = ''), ARM_MS);
}
$effect(() => () => clearTimeout(armTimer));
</script>
<section class="lobby" class:compact aria-label={t.lobbyTitle}>
@@ -135,17 +114,16 @@
blocks the frame loop, and this is the same control asking
again rather than a second one appearing. -->
{#if game.isOwner && !player.isMe}
<button
type="button"
<ArmedButton
class="kick"
class:arming={armedKick === player.playerId}
label={t.kickPlayer}
confirmLabel={t.kickSure}
disabled={player.ready}
aria-label={armedKick === player.playerId ? t.kickSure : t.kickPlayer}
data-testid={`kick-${player.playerId}`}
onclick={() => armOrKick(player.playerId)}
testid={`kick-${player.playerId}`}
onconfirm={() => onkick(player.playerId)}
>
×
</button>
</ArmedButton>
{/if}
</span>
</li>
@@ -354,8 +332,11 @@
* a quarter of a screen to a four-seat lobby that is already long. The
* touch target is the full 44 all the same, expanded out of the flow by a
* pseudo-element so the row keeps its height.
*
* :global(): ArmedButton renders its own <button>, which this component's
* scoped-style attribute never reaches.
*/
.kick {
:global(.kick) {
position: relative;
width: 36px;
height: 36px;
@@ -369,17 +350,17 @@
line-height: 1;
}
.kick::after {
:global(.kick::after) {
content: '';
position: absolute;
inset: -4px;
}
.kick:disabled {
:global(.kick:disabled) {
opacity: 0.35;
}
.kick.arming {
:global(.kick.arming) {
border-color: var(--danger);
background: var(--danger-soft);
color: var(--danger);