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>
|
<script>
|
||||||
|
import ArmedButton from '$lib/components/ArmedButton.svelte';
|
||||||
import ChainHistory from '$lib/components/ChainHistory.svelte';
|
import ChainHistory from '$lib/components/ChainHistory.svelte';
|
||||||
import ConnectionBadge from '$lib/components/ConnectionBadge.svelte';
|
import ConnectionBadge from '$lib/components/ConnectionBadge.svelte';
|
||||||
import CountdownRing from '$lib/components/CountdownRing.svelte';
|
import CountdownRing from '$lib/components/CountdownRing.svelte';
|
||||||
@@ -42,17 +43,6 @@
|
|||||||
onchatopen
|
onchatopen
|
||||||
} = $props();
|
} = $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
|
// Whose turn it is, said by name. With four people at the table "the
|
||||||
// opponent is thinking" stops naming anybody.
|
// opponent is thinking" stops naming anybody.
|
||||||
const turnLabel = $derived.by(() => {
|
const turnLabel = $derived.by(() => {
|
||||||
@@ -68,61 +58,9 @@
|
|||||||
// is to leave the room, which the lobby's own button does.
|
// is to leave the room, which the lobby's own button does.
|
||||||
const canResign = $derived(game.state.myTurn && !offline);
|
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,
|
// A claim is the same offer resign is: made only on the player's own turn,
|
||||||
// on the same reasoning canResign already states.
|
// on the same reasoning canResign already states.
|
||||||
const canClaimDeadEnd = $derived(game.state.myTurn && !offline);
|
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>
|
</script>
|
||||||
|
|
||||||
<section class="board" data-phase={game.state.phase}>
|
<section class="board" data-phase={game.state.phase}>
|
||||||
@@ -237,15 +175,13 @@
|
|||||||
the current syllable, which only means something on this
|
the current syllable, which only means something on this
|
||||||
player's own turn — unlike resign, there is no "not yet" state
|
player's own turn — unlike resign, there is no "not yet" state
|
||||||
worth showing for it off turn. -->
|
worth showing for it off turn. -->
|
||||||
<button
|
<ArmedButton
|
||||||
type="button"
|
|
||||||
class="claim-dead-end"
|
class="claim-dead-end"
|
||||||
class:arming={claimArming}
|
label={t.claimDeadEnd}
|
||||||
|
confirmLabel={t.claimDeadEndSure}
|
||||||
disabled={!canClaimDeadEnd}
|
disabled={!canClaimDeadEnd}
|
||||||
onclick={armOrClaim}
|
onconfirm={onclaimdeadend}
|
||||||
>
|
/>
|
||||||
{claimArming ? t.claimDeadEndSure : t.claimDeadEnd}
|
|
||||||
</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if game.state.claimError}
|
{#if game.state.claimError}
|
||||||
<p class="claim-error" role="alert">
|
<p class="claim-error" role="alert">
|
||||||
@@ -265,15 +201,13 @@
|
|||||||
that grows, and a button under it walks off the bottom of the screen
|
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. -->
|
exactly as the game gets long enough to want to give up on. -->
|
||||||
{#if game.state.phase === 'playing' && !game.iAmOut}
|
{#if game.state.phase === 'playing' && !game.iAmOut}
|
||||||
<button
|
<ArmedButton
|
||||||
type="button"
|
|
||||||
class="resign"
|
class="resign"
|
||||||
class:arming
|
label={t.resign}
|
||||||
|
confirmLabel={t.resignSure}
|
||||||
disabled={!canResign}
|
disabled={!canResign}
|
||||||
onclick={armOrResign}
|
onconfirm={onresign}
|
||||||
>
|
/>
|
||||||
{arming ? t.resignSure : t.resign}
|
|
||||||
</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<ChainHistory />
|
<ChainHistory />
|
||||||
@@ -418,10 +352,14 @@
|
|||||||
text-align: center;
|
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
|
/* 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
|
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. */
|
it ends the game, subordinate because it is not the way to play it. */
|
||||||
.resign {
|
:global(.resign) {
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
/* Below the 44px the rest of the controls keep, deliberately: this is
|
/* 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
|
the one button here nobody is trying to hit, it takes two presses to
|
||||||
@@ -438,20 +376,20 @@
|
|||||||
transition: background-color 150ms ease-out;
|
transition: background-color 150ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resign:hover:enabled {
|
:global(.resign:hover:enabled) {
|
||||||
background: var(--danger-soft);
|
background: var(--danger-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Off turn: still there, so the way out of the game does not appear and
|
/* 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
|
disappear under the player's thumb every handover, but plainly not the
|
||||||
thing to press yet. */
|
thing to press yet. */
|
||||||
.resign:disabled {
|
:global(.resign:disabled) {
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Armed, and saying so: the second press is the one that ends the game. */
|
/* Armed, and saying so: the second press is the one that ends the game. */
|
||||||
.resign.arming {
|
:global(.resign.arming) {
|
||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
background: var(--danger-soft);
|
background: var(--danger-soft);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -461,7 +399,7 @@
|
|||||||
the syllable on screen right now, so it reads as part of answering it
|
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
|
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. */
|
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;
|
align-self: flex-start;
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
padding: var(--space-1) var(--space-3);
|
padding: var(--space-1) var(--space-3);
|
||||||
@@ -473,16 +411,16 @@
|
|||||||
transition: background-color 150ms ease-out;
|
transition: background-color 150ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.claim-dead-end:hover:enabled {
|
:global(.claim-dead-end:hover:enabled) {
|
||||||
background: var(--surface-alt);
|
background: var(--surface-alt);
|
||||||
}
|
}
|
||||||
|
|
||||||
.claim-dead-end:disabled {
|
:global(.claim-dead-end:disabled) {
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.claim-dead-end.arming {
|
:global(.claim-dead-end.arming) {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import ArmedButton from '$lib/components/ArmedButton.svelte';
|
||||||
import ConnectionBadge from '$lib/components/ConnectionBadge.svelte';
|
import ConnectionBadge from '$lib/components/ConnectionBadge.svelte';
|
||||||
import PlayerStatus from '$lib/components/PlayerStatus.svelte';
|
import PlayerStatus from '$lib/components/PlayerStatus.svelte';
|
||||||
import RoomCodePanel from '$lib/components/RoomCodePanel.svelte';
|
import RoomCodePanel from '$lib/components/RoomCodePanel.svelte';
|
||||||
@@ -33,9 +34,6 @@
|
|||||||
*/
|
*/
|
||||||
let { compact = false, actionHeld = false, onready, onstart, onkick, onleave } = $props();
|
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);
|
const s = $derived(game.state);
|
||||||
// The seats nobody is in yet, drawn so a room that is waiting on people
|
// 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.
|
// 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>
|
</script>
|
||||||
|
|
||||||
<section class="lobby" class:compact aria-label={t.lobbyTitle}>
|
<section class="lobby" class:compact aria-label={t.lobbyTitle}>
|
||||||
@@ -135,17 +114,16 @@
|
|||||||
blocks the frame loop, and this is the same control asking
|
blocks the frame loop, and this is the same control asking
|
||||||
again rather than a second one appearing. -->
|
again rather than a second one appearing. -->
|
||||||
{#if game.isOwner && !player.isMe}
|
{#if game.isOwner && !player.isMe}
|
||||||
<button
|
<ArmedButton
|
||||||
type="button"
|
|
||||||
class="kick"
|
class="kick"
|
||||||
class:arming={armedKick === player.playerId}
|
label={t.kickPlayer}
|
||||||
|
confirmLabel={t.kickSure}
|
||||||
disabled={player.ready}
|
disabled={player.ready}
|
||||||
aria-label={armedKick === player.playerId ? t.kickSure : t.kickPlayer}
|
testid={`kick-${player.playerId}`}
|
||||||
data-testid={`kick-${player.playerId}`}
|
onconfirm={() => onkick(player.playerId)}
|
||||||
onclick={() => armOrKick(player.playerId)}
|
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</ArmedButton>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -354,8 +332,11 @@
|
|||||||
* a quarter of a screen to a four-seat lobby that is already long. The
|
* 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
|
* touch target is the full 44 all the same, expanded out of the flow by a
|
||||||
* pseudo-element so the row keeps its height.
|
* 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;
|
position: relative;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
@@ -369,17 +350,17 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kick::after {
|
:global(.kick::after) {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: -4px;
|
inset: -4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kick:disabled {
|
:global(.kick:disabled) {
|
||||||
opacity: 0.35;
|
opacity: 0.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kick.arming {
|
:global(.kick.arming) {
|
||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
background: var(--danger-soft);
|
background: var(--danger-soft);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
|
|||||||
Reference in new issue
Block a user