mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
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:
1 parent
f0c2334228
commit
f0bcb6084a
3 files changed
+121
-118
No files matched your search
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user