UI: tab-bar navigation — drop the hamburger
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
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
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.
This commit is contained in:
@@ -1,13 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
import { insideTelegram } from '../lib/telegram';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import Spinner from './Spinner.svelte';
|
||||
|
||||
let { title, back, menu, grow = false }: { title: string; back?: string; menu?: Snippet; grow?: boolean } =
|
||||
$props();
|
||||
let { title, back, grow = false }: { title: string; back?: string; grow?: boolean } = $props();
|
||||
|
||||
// Inside Telegram the native header back button (App.svelte) is the back control, so
|
||||
// the app's own chevron is hidden to avoid two back affordances.
|
||||
@@ -28,7 +26,8 @@
|
||||
{:else}
|
||||
<h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1>
|
||||
{/if}
|
||||
<div class="end">{#if menu}{@render menu()}{/if}</div>
|
||||
<!-- A right-hand spacer balances the back button so the title stays centred. -->
|
||||
<span class="spacer"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -75,18 +74,13 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
.icon,
|
||||
.spacer,
|
||||
.end {
|
||||
.spacer {
|
||||
min-width: 40px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.end {
|
||||
width: auto;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.back {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -108,9 +102,8 @@
|
||||
}
|
||||
/* Telegram fullscreen: TG's native nav occupies the band between the device notch
|
||||
(--tg-safe-top) and --tg-content-top. Our header spans that full band (so the layout below
|
||||
is unchanged) and centres the title + menu as a pair (hamburger right of the title) within
|
||||
it, BELOW the notch — lining them up vertically with Telegram's own back/menu controls,
|
||||
which sit in the band's corners. */
|
||||
is unchanged) and centres the title within it, BELOW the notch — lining it up vertically
|
||||
with Telegram's own back/menu controls, which sit in the band's corners. */
|
||||
:global(html.tg-fullscreen) .bar {
|
||||
min-height: var(--tg-content-top);
|
||||
box-sizing: border-box;
|
||||
@@ -129,7 +122,4 @@
|
||||
:global(html.tg-fullscreen) h1 {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
:global(html.tg-fullscreen) .end {
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
// A press-and-hold control: a short tap opens a popover (the consumer renders its
|
||||
// buttons), a ~holdMs hold runs `onhold` immediately. Reused by MakeMove and the
|
||||
// game tab-bar confirmations. The popover snippet receives a `close` callback.
|
||||
let {
|
||||
onhold,
|
||||
holdMs = 700,
|
||||
disabled = false,
|
||||
triggerClass = '',
|
||||
trigger,
|
||||
popover,
|
||||
}: {
|
||||
onhold: () => void;
|
||||
holdMs?: number;
|
||||
disabled?: boolean;
|
||||
triggerClass?: string;
|
||||
trigger: Snippet;
|
||||
popover: Snippet<[() => void]>;
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let held = false;
|
||||
|
||||
function clear() {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
function down() {
|
||||
if (disabled) return;
|
||||
held = false;
|
||||
clear();
|
||||
timer = setTimeout(() => {
|
||||
held = true;
|
||||
open = false;
|
||||
onhold();
|
||||
}, holdMs);
|
||||
}
|
||||
function up() {
|
||||
clear();
|
||||
if (!held && !disabled) open = true;
|
||||
}
|
||||
function leave() {
|
||||
clear();
|
||||
}
|
||||
const close = () => (open = false);
|
||||
</script>
|
||||
|
||||
<div class="hc">
|
||||
<button
|
||||
class="trigger {triggerClass}"
|
||||
{disabled}
|
||||
onpointerdown={down}
|
||||
onpointerup={up}
|
||||
onpointerleave={leave}
|
||||
onpointercancel={leave}
|
||||
>
|
||||
{@render trigger()}
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="backdrop" onclick={close}></div>
|
||||
<div class="popover">{@render popover(close)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hc {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
.trigger {
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 18;
|
||||
}
|
||||
.popover {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 19;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
white-space: nowrap;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 4px;
|
||||
min-width: 132px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,131 +0,0 @@
|
||||
<script lang="ts">
|
||||
// The header hamburger + dropdown, shared by the lobby and game screens. An item
|
||||
// may carry a numeric badge; the hamburger shows the total via the `badge` prop so
|
||||
// a pending count is visible while the menu is closed.
|
||||
interface MenuItem {
|
||||
label: string;
|
||||
onclick: () => void;
|
||||
badge?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
let { items, badge = 0 }: { items: MenuItem[]; badge?: number } = $props();
|
||||
let open = $state(false);
|
||||
|
||||
function pick(fn: () => void) {
|
||||
open = false;
|
||||
fn();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="menu">
|
||||
<button class="burger" onclick={() => (open = !open)} aria-label="Menu">
|
||||
<span></span><span></span><span></span>
|
||||
{#if badge > 0}<span class="dot" data-testid="menu-badge">{badge}</span>{/if}
|
||||
</button>
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="backdrop" onclick={() => (open = false)}></div>
|
||||
<div class="dropdown">
|
||||
{#each items as it (it.label)}
|
||||
<button onclick={() => pick(it.onclick)} disabled={it.disabled}>
|
||||
<span>{it.label}</span>
|
||||
{#if it.badge}<span class="idot">{it.badge}</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.menu {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
.burger {
|
||||
position: relative;
|
||||
background: none;
|
||||
border: none;
|
||||
width: 44px;
|
||||
height: 38px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 0 10px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.dot {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: 0;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--danger, #c0392b);
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
.idot {
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--danger, #c0392b);
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
.burger span:not(.dot) {
|
||||
display: block;
|
||||
height: 3px;
|
||||
background: var(--text);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 8;
|
||||
}
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 6px);
|
||||
z-index: 9;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 170px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dropdown button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.dropdown button:hover:not(:disabled) {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.dropdown button:disabled {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
@@ -10,7 +10,6 @@
|
||||
let {
|
||||
title,
|
||||
back,
|
||||
menu,
|
||||
tabbar,
|
||||
children,
|
||||
scroll = true,
|
||||
@@ -19,7 +18,6 @@
|
||||
}: {
|
||||
title: string;
|
||||
back?: string;
|
||||
menu?: Snippet;
|
||||
tabbar?: Snippet;
|
||||
children?: Snippet;
|
||||
scroll?: boolean;
|
||||
@@ -58,7 +56,7 @@
|
||||
</script>
|
||||
|
||||
<div class="screen">
|
||||
<Header {title} {back} {menu} grow={growNav} />
|
||||
<Header {title} {back} grow={growNav} />
|
||||
{#if SHOW_AD_BANNER}<AdBanner />{/if}
|
||||
<main class="content" class:scroll class:fill={!growNav} class:column>{@render children?.()}</main>
|
||||
{#if tabbar}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
// The bottom tab bar: square borderless buttons, evenly distributed, mobile-OS feel.
|
||||
// Direct children (plain `.tab` buttons or HoldConfirm wrappers) share the width.
|
||||
// Direct children (plain `.tab` buttons or TapConfirm wrappers) share the width.
|
||||
let { children }: { children?: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
@@ -53,6 +53,29 @@
|
||||
:global(.tab:active:not(:disabled) .sq) {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
/* A tab that navigates between peer views (Settings / Comms hubs) stays highlighted
|
||||
while selected: a filled square with an accent underline, distinct from the
|
||||
momentary press tint above. */
|
||||
:global(.tab.active .sq) {
|
||||
background: var(--surface-2);
|
||||
box-shadow: inset 0 -2px 0 var(--accent);
|
||||
}
|
||||
/* A small count badge on the icon square's corner (lobby ⚙️, the Friends tab, the
|
||||
hint count) — one shared style so every tab badge reads the same. */
|
||||
:global(.tab .badge) {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
right: -3px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
border-radius: 999px;
|
||||
min-width: 15px;
|
||||
padding: 0 3px;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
}
|
||||
:global(.tab .lbl) {
|
||||
font-size: 0.62rem;
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user