Files
scrabble-game/ui/src/components/TapConfirm.svelte
T
Ilia Denisov fc1261e078
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 39s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 59s
UI: tab-bar navigation — drop the hamburger
Replace Menu.svelte (hamburger) everywhere with tab-bar navigation:
- Settings hub (SettingsHub) from the lobby ⚙️ tab: Settings/Profile/
  Friends/About as in-place tabs, back → lobby; the lobby ⚙️ badge counts
  incoming friend requests (invitations keep their own lobby section).
- Comms hub (CommsHub) from the move-history 💬: Chat/Dictionary tabs,
  back → game; Dictionary only while the game is active.
- Game menu items relocate into the open history: 🏁 leave / 📤 export in
  the header, 🤝 add-friend per opponent card, 💬 comms; unread chat is
  badged on the score bar + the 💬.
- TapConfirm (tap → fading  → tap) replaces the Skip/Hint press-and-hold
  popovers and drives the add-friend confirm.
- Fix the move-history "jump": the slid board is inert and the stage can't
  scroll, so a swipe up genuinely closes the history.

Remove Menu.svelte + HoldConfirm.svelte. Docs: UI_DESIGN, FUNCTIONAL(+ru),
PRERELEASE. UI check/unit/build/bundle/e2e (Chromium+WebKit) all green.
2026-06-11 14:13:54 +02:00

100 lines
2.7 KiB
Svelte

<script lang="ts">
import type { Snippet } from 'svelte';
import { onDestroy } from 'svelte';
import { app } from '../lib/app.svelte';
import { createTapConfirm } from '../lib/tapconfirm';
// A two-tap confirmation control: the first tap on the trigger arms a ~durationMs
// window during which a ✅ is shown (fading out, unless reduce-motion); a tap on the
// ✅ within the window confirms. Replaces the press-and-hold and pop-up confirms.
// onConfirming reports the window opening/closing so a parent can swap adjacent content
// (e.g. a score for an "Add friend?" label). The click never bubbles, so the control can
// sit inside another clickable surface (the score bar) without triggering it.
let {
onconfirm,
durationMs = 2000,
disabled = false,
triggerClass = '',
label,
onConfirming,
children,
}: {
onconfirm: () => void;
durationMs?: number;
disabled?: boolean;
triggerClass?: string;
/** Accessible label for the control (applied in both states). */
label?: string;
/** Notified whenever the confirming flag flips, so a parent can react. */
onConfirming?: (confirming: boolean) => void;
children: Snippet;
} = $props();
let confirming = $state(false);
const ctl = createTapConfirm({
get durationMs() {
return durationMs;
},
onConfirm: () => onconfirm(),
onChange: (c) => {
confirming = c;
onConfirming?.(c);
},
});
onDestroy(() => ctl.dispose());
// A control disabled mid-window (e.g. it became the opponent's turn) closes it.
$effect(() => {
if (disabled && confirming) ctl.cancel();
});
function onclick(e: MouseEvent) {
e.stopPropagation();
if (confirming) ctl.confirm();
else if (!disabled) ctl.arm();
}
</script>
<button class="tapconfirm {triggerClass}" class:confirming {disabled} aria-label={label} {onclick}>
{#if confirming}
<span class="sq ok" class:fade={!app.reduceMotion} style="--tc-ms: {durationMs}ms"></span>
{:else}
{@render children()}
{/if}
</button>
<style>
.tapconfirm {
background: none;
border: none;
padding: 0;
color: inherit;
font: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
user-select: none;
-webkit-user-select: none;
}
.tapconfirm:disabled {
opacity: 0.4;
}
/* Outside the tab bar (where :global(.tab .sq) supplies the size) the confirm icon
needs a size of its own: inherit the trigger's, so ✅ matches the idle icon. */
.tapconfirm:not(.tab) .sq {
display: inline-grid;
place-items: center;
font-size: 1em;
}
.ok.fade {
animation: tc-fade var(--tc-ms) linear forwards;
}
@keyframes tc-fade {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
</style>