UI: tab-bar navigation — drop the hamburger #39
+3
-2
@@ -35,8 +35,9 @@ Login uses `Screen`.
|
|||||||
routes `/settings|/profile|/friends|/about` and `/game/:id/{chat,check}` survive as hub
|
routes `/settings|/profile|/friends|/about` and `/game/:id/{chat,check}` survive as hub
|
||||||
entry points (so a Telegram friend-code deep-link still lands on the Friends tab).
|
entry points (so a Telegram friend-code deep-link still lands on the Friends tab).
|
||||||
- **Tab bar** (`TabBar.svelte`): square, borderless, evenly distributed buttons — a large
|
- **Tab bar** (`TabBar.svelte`): square, borderless, evenly distributed buttons — a large
|
||||||
emoji icon over a tiny truncated label (hub tabs are **icon-only**). A press highlights a
|
emoji icon over a tiny truncated label (the icon is `aria-hidden`, so the label names the
|
||||||
rounded **square** behind the icon; a hub's **selected** tab stays highlighted (a filled
|
button). A press highlights a rounded **square** behind the icon; a hub's **selected** tab
|
||||||
|
stays highlighted (a filled
|
||||||
square with an accent underline). A red count **badge** rides the icon's corner — on the
|
square with an accent underline). A red count **badge** rides the icon's corner — on the
|
||||||
lobby ⚙️ tab and the hub's 🤝 Friends tab for pending incoming friend requests (invitations
|
lobby ⚙️ tab and the hub's 🤝 Friends tab for pending incoming friend requests (invitations
|
||||||
keep their own lobby section), and on the Hint tab for the remaining count. No text
|
keep their own lobby section), and on the Hint tab for the remaining count. No text
|
||||||
|
|||||||
+2
-2
@@ -172,7 +172,7 @@ test('check-word sanitises input and shows a verdict', async ({ page }) => {
|
|||||||
await page.locator('.scoreboard').click(); // open the history
|
await page.locator('.scoreboard').click(); // open the history
|
||||||
await page.getByRole('button', { name: 'Chat' }).click(); // 💬 -> comms hub
|
await page.getByRole('button', { name: 'Chat' }).click(); // 💬 -> comms hub
|
||||||
await expect(page.locator('.pane')).toHaveCount(1);
|
await expect(page.locator('.pane')).toHaveCount(1);
|
||||||
await page.getByRole('button', { name: 'Check word' }).click(); // 🔎 -> dictionary tab
|
await page.getByRole('button', { name: 'Dictionary' }).click(); // 🔎 -> dictionary tab
|
||||||
|
|
||||||
const input = page.locator('.check input');
|
const input = page.locator('.check input');
|
||||||
await input.fill('qz9!a'); // digits/punctuation dropped, letters upper-cased
|
await input.fill('qz9!a'); // digits/punctuation dropped, letters upper-cased
|
||||||
@@ -222,7 +222,7 @@ test('comms hub: chat and dictionary share a screen, back returns to the game',
|
|||||||
await expect(page.locator('.chat')).toBeVisible();
|
await expect(page.locator('.chat')).toBeVisible();
|
||||||
|
|
||||||
// The Dictionary tab switches in place (same screen, no navigation).
|
// The Dictionary tab switches in place (same screen, no navigation).
|
||||||
await page.getByRole('button', { name: 'Check word' }).click();
|
await page.getByRole('button', { name: 'Dictionary' }).click();
|
||||||
await expect(page.locator('.check input')).toBeVisible();
|
await expect(page.locator('.check input')).toBeVisible();
|
||||||
|
|
||||||
// The header back chevron returns to the game.
|
// The header back chevron returns to the game.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ async function loginLobby(page: Page): Promise<void> {
|
|||||||
|
|
||||||
// The Settings hub (the lobby ⚙️ tab) hosts Settings / Profile / Friends / About as in-place
|
// The Settings hub (the lobby ⚙️ tab) hosts Settings / Profile / Friends / About as in-place
|
||||||
// tabs; the back control always returns to the lobby. Tabs are icon-only with an aria-label.
|
// tabs; the back control always returns to the lobby. Tabs are icon-only with an aria-label.
|
||||||
async function openSettingsTab(page: Page, tab: 'Profile' | 'Friends' | 'About'): Promise<void> {
|
async function openSettingsTab(page: Page, tab: 'Profile' | 'Friends' | 'Info'): Promise<void> {
|
||||||
await page.getByRole('button', { name: /Settings/ }).click(); // lobby ⚙️ tab
|
await page.getByRole('button', { name: /Settings/ }).click(); // lobby ⚙️ tab
|
||||||
await expect(page.locator('.pane')).toHaveCount(1); // let the slide settle
|
await expect(page.locator('.pane')).toHaveCount(1); // let the slide settle
|
||||||
await page.getByRole('button', { name: tab, exact: true }).click();
|
await page.getByRole('button', { name: tab, exact: true }).click();
|
||||||
@@ -70,7 +70,7 @@ test('settings hub: tabs switch in place and back returns to the lobby', async (
|
|||||||
await expect(page.getByText('Friend requests')).toBeVisible();
|
await expect(page.getByText('Friend requests')).toBeVisible();
|
||||||
await expect(page.locator('.pane')).toHaveCount(1);
|
await expect(page.locator('.pane')).toHaveCount(1);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'About', exact: true }).click();
|
await page.getByRole('button', { name: 'Info', exact: true }).click();
|
||||||
await expect(page.getByText(/Version/)).toBeVisible();
|
await expect(page.getByText(/Version/)).toBeVisible();
|
||||||
|
|
||||||
// Back returns to the lobby from any tab.
|
// Back returns to the lobby from any tab.
|
||||||
@@ -114,7 +114,7 @@ test('finished game draws an inert footer and trims live-only controls', async (
|
|||||||
// The comms hub offers Chat only — the Dictionary tab is hidden for a finished game.
|
// The comms hub offers Chat only — the Dictionary tab is hidden for a finished game.
|
||||||
await page.getByRole('button', { name: 'Chat' }).click(); // 💬
|
await page.getByRole('button', { name: 'Chat' }).click(); // 💬
|
||||||
await expect(page.locator('.pane')).toHaveCount(1);
|
await expect(page.locator('.pane')).toHaveCount(1);
|
||||||
await expect(page.getByRole('button', { name: 'Check word' })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: 'Dictionary' })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('lobby: hiding a finished game removes it (kebab → ❌), keeping the others', async ({ page }) => {
|
test('lobby: hiding a finished game removes it (kebab → ❌), keeping the others', async ({ page }) => {
|
||||||
|
|||||||
@@ -105,14 +105,14 @@
|
|||||||
is unchanged) and centres the title within it, BELOW the notch — lining it up vertically
|
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. */
|
with Telegram's own back/menu controls, which sit in the band's corners. */
|
||||||
:global(html.tg-fullscreen) .bar {
|
:global(html.tg-fullscreen) .bar {
|
||||||
min-height: var(--tg-content-top);
|
/* The bar spans the Telegram nav band and then **+20px** past --tg-content-top, so the
|
||||||
|
native controls aren't flush against our content. Without the (removed) hamburger the
|
||||||
|
title alone left the bar sitting right on the band; the extra height restores the gap.
|
||||||
|
padding-top clears the notch; the title centres in the band. (Owner-tunable height.) */
|
||||||
|
min-height: calc(var(--tg-content-top) + 20px);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
/* +12px of vertical breathing room (6 above / 6 below the centred content, on top of the
|
|
||||||
notch) so Telegram's native controls aren't flush against our header. Applied as
|
|
||||||
padding because the bar is sized by its content here, not by min-height (owner review
|
|
||||||
tweaks). */
|
|
||||||
padding-top: calc(var(--tg-safe-top) + 6px);
|
padding-top: calc(var(--tg-safe-top) + 6px);
|
||||||
padding-bottom: 6px;
|
padding-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,12 +38,12 @@
|
|||||||
|
|
||||||
{#snippet tabbar()}
|
{#snippet tabbar()}
|
||||||
<TabBar>
|
<TabBar>
|
||||||
<button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')} aria-label={t('game.chat')}>
|
<button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}>
|
||||||
<span class="sq">💬</span>
|
<span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span>
|
||||||
</button>
|
</button>
|
||||||
{#if active}
|
{#if active}
|
||||||
<button class="tab" class:active={tab === 'dictionary'} onclick={() => (tab = 'dictionary')} aria-label={t('game.checkWord')}>
|
<button class="tab" class:active={tab === 'dictionary'} onclick={() => (tab = 'dictionary')}>
|
||||||
<span class="sq">🔎</span>
|
<span class="sq" aria-hidden="true">🔎</span><span class="lbl">{t('game.dictionary')}</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</TabBar>
|
</TabBar>
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export const en = {
|
|||||||
'game.hint': 'Hint',
|
'game.hint': 'Hint',
|
||||||
'game.chat': 'Chat',
|
'game.chat': 'Chat',
|
||||||
'game.checkWord': 'Check word',
|
'game.checkWord': 'Check word',
|
||||||
|
'game.dictionary': 'Dictionary',
|
||||||
'game.dropGame': 'Drop game',
|
'game.dropGame': 'Drop game',
|
||||||
'game.preview': 'Scores {n}',
|
'game.preview': 'Scores {n}',
|
||||||
'game.previewIllegal': 'Not a legal move',
|
'game.previewIllegal': 'Not a legal move',
|
||||||
@@ -149,6 +150,7 @@ export const en = {
|
|||||||
'settings.reduceMotion': 'Reduce motion',
|
'settings.reduceMotion': 'Reduce motion',
|
||||||
|
|
||||||
'about.title': 'About',
|
'about.title': 'About',
|
||||||
|
'about.tab': 'Info',
|
||||||
'about.description': 'A multiplatform Scrabble game.',
|
'about.description': 'A multiplatform Scrabble game.',
|
||||||
'about.version': 'Version {v}',
|
'about.version': 'Version {v}',
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'game.hint': 'Подсказка',
|
'game.hint': 'Подсказка',
|
||||||
'game.chat': 'Чат',
|
'game.chat': 'Чат',
|
||||||
'game.checkWord': 'Проверить слово',
|
'game.checkWord': 'Проверить слово',
|
||||||
|
'game.dictionary': 'Словарь',
|
||||||
'game.dropGame': 'Покинуть игру',
|
'game.dropGame': 'Покинуть игру',
|
||||||
'game.preview': 'Очков: {n}',
|
'game.preview': 'Очков: {n}',
|
||||||
'game.previewIllegal': 'Недопустимый ход',
|
'game.previewIllegal': 'Недопустимый ход',
|
||||||
@@ -150,6 +151,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'settings.reduceMotion': 'Меньше анимаций',
|
'settings.reduceMotion': 'Меньше анимаций',
|
||||||
|
|
||||||
'about.title': 'О программе',
|
'about.title': 'О программе',
|
||||||
|
'about.tab': 'Инфо',
|
||||||
'about.description': 'Мультиплатформенная игра в скрабл.',
|
'about.description': 'Мультиплатформенная игра в скрабл.',
|
||||||
'about.version': 'Версия {v}',
|
'about.version': 'Версия {v}',
|
||||||
|
|
||||||
|
|||||||
@@ -45,19 +45,19 @@
|
|||||||
|
|
||||||
{#snippet tabbar()}
|
{#snippet tabbar()}
|
||||||
<TabBar>
|
<TabBar>
|
||||||
<button class="tab" class:active={tab === 'settings'} onclick={() => (tab = 'settings')} aria-label={t('settings.title')}>
|
<button class="tab" class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>
|
||||||
<span class="sq">⚙️</span>
|
<span class="sq" aria-hidden="true">⚙️</span><span class="lbl">{t('settings.title')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" class:active={tab === 'profile'} onclick={() => (tab = 'profile')} aria-label={t('profile.title')}>
|
<button class="tab" class:active={tab === 'profile'} onclick={() => (tab = 'profile')}>
|
||||||
<span class="sq">👤</span>
|
<span class="sq" aria-hidden="true">👤</span><span class="lbl">{t('profile.title')}</span>
|
||||||
</button>
|
</button>
|
||||||
{#if !guest}
|
{#if !guest}
|
||||||
<button class="tab" class:active={tab === 'friends'} onclick={() => (tab = 'friends')} aria-label={t('friends.title')}>
|
<button class="tab" class:active={tab === 'friends'} onclick={() => (tab = 'friends')}>
|
||||||
<span class="sq">🤝{#if app.notifications > 0}<span class="badge">{app.notifications}</span>{/if}</span>
|
<span class="sq" aria-hidden="true">🤝{#if app.notifications > 0}<span class="badge">{app.notifications}</span>{/if}</span><span class="lbl">{t('friends.title')}</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="tab" class:active={tab === 'about'} onclick={() => (tab = 'about')} aria-label={t('about.title')}>
|
<button class="tab" class:active={tab === 'about'} onclick={() => (tab = 'about')}>
|
||||||
<span class="sq">ℹ️</span>
|
<span class="sq" aria-hidden="true">ℹ️</span><span class="lbl">{t('about.tab')}</span>
|
||||||
</button>
|
</button>
|
||||||
</TabBar>
|
</TabBar>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
Reference in New Issue
Block a user