Stage 8 polish: keyboard-aware modals, consistent select pickers, required game type
Tests · UI / test (push) Successful in 17s

Third owner-review pass (iPhone):
- Modals (and the chat) size their backdrop to window.visualViewport, so they stay
  fully above the software keyboard (dvh alone left the sheet partly behind it).
- On the owner's call, every profile / new-game picker is a native <select> for
  consistent cross-platform behaviour: the away window returns to hour + 10-minute
  selects (which also avoids the iOS time-wheel "clear" button), alongside the offset
  timezone and the game-type / move-time / hints selects. Native time/wheel inputs
  render differently per OS and cannot be forced to match.
- New-game "play with friends" has no preselected game type — an explicit, required
  pick (empty placeholder); Send invitation stays disabled until both a type and a
  friend are chosen. A smart default (from play history / language) is TODO-6.
This commit is contained in:
Ilia Denisov
2026-06-03 23:14:51 +02:00
parent 8b83543632
commit b7d469a06e
4 changed files with 93 additions and 32 deletions
+35 -2
View File
@@ -6,11 +6,38 @@
onclose,
children,
}: { title?: string; onclose?: () => void; children?: Snippet } = $props();
// Track the visual viewport so the backdrop covers only the area above an open
// mobile keyboard: dvh alone shrinks the sheet but the fixed, layout-viewport
// backdrop still centres it behind the keyboard. Sizing the backdrop to
// visualViewport keeps the sheet (and the start of a chat) fully on screen.
let vh = $state(0);
let top = $state(0);
$effect(() => {
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
if (!vv) return;
const update = () => {
vh = vv.height;
top = vv.offsetTop;
};
update();
vv.addEventListener('resize', update);
vv.addEventListener('scroll', update);
return () => {
vv.removeEventListener('resize', update);
vv.removeEventListener('scroll', update);
};
});
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="backdrop" onclick={() => onclose?.()}>
<div
class="backdrop"
style:height={vh ? `${vh}px` : null}
style:top={vh ? `${top}px` : null}
onclick={() => onclose?.()}
>
<div class="sheet" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()}>
{#if title}<h2>{title}</h2>{/if}
{@render children?.()}
@@ -20,7 +47,13 @@
<style>
.backdrop {
position: fixed;
inset: 0;
left: 0;
right: 0;
top: 0;
/* Base fallback; overridden inline to the visual-viewport height/top so the
backdrop (and the centred sheet) stay above an open mobile keyboard. */
height: 100dvh;
box-sizing: border-box;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
+10 -4
View File
@@ -62,7 +62,9 @@
let friends = $state<AccountRef[]>([]);
let selected = $state<string[]>([]);
let friendFilter = $state('');
let inviteVariant = $state<Variant>('english');
// No default game type yet — the player must pick one (a smarter default from play
// history / language is TODO-6). '' renders the disabled placeholder option.
let inviteVariant = $state<Variant | ''>('');
let timeoutSecs = $state(86400);
let hints = $state(1);
@@ -86,7 +88,7 @@
}
async function sendInvite() {
if (selected.length === 0 || selected.length > 3) return;
if (selected.length === 0 || selected.length > 3 || !inviteVariant) return;
try {
await gateway.invitationCreate(selected, {
variant: inviteVariant,
@@ -148,7 +150,8 @@
<div class="settings-row">
<label class="field">
<span>{t('new.gameType')}</span>
<select bind:value={inviteVariant}>
<select bind:value={inviteVariant} class:placeholder={!inviteVariant}>
<option value="" disabled></option>
{#each variants as v (v.id)}<option value={v.id}>{t(v.label)}</option>{/each}
</select>
</label>
@@ -165,7 +168,7 @@
</select>
</label>
</div>
<button class="invite" disabled={selected.length === 0} onclick={sendInvite}>{t('new.invite')}</button>
<button class="invite" disabled={selected.length === 0 || !inviteVariant} onclick={sendInvite}>{t('new.invite')}</button>
</div>
{/if}
{/if}
@@ -289,6 +292,9 @@
color: var(--text);
border-radius: var(--radius-sm);
}
.field select.placeholder {
color: var(--text-muted);
}
.muted {
color: var(--text-muted);
margin: 0;
+32 -19
View File
@@ -5,6 +5,8 @@
import { t } from '../lib/i18n/index.svelte';
import {
awayDurationOk,
awayHours,
awayMinutes,
browserOffset,
isOffsetZone,
timezoneOffsets,
@@ -15,8 +17,12 @@
let editing = $state(false);
let dn = $state('');
let tz = $state('+00:00');
let awayStart = $state('00:00');
let awayEnd = $state('07:00');
// Away start/end as hour + 10-minute parts, so the picker is a <select> like every
// other profile control (consistent native control across iOS / desktop).
let startH = $state('00');
let startM = $state('00');
let endH = $state('07');
let endM = $state('00');
let blockChat = $state(false);
let blockFriendRequests = $state(false);
let emailInput = $state('');
@@ -27,21 +33,25 @@
const b = browserOffset();
return timezoneOffsets.includes(b) ? b : '+00:00';
}
function clockOr(value: string, fallback: string): string {
return /^\d{2}:\d{2}$/.test(value) ? value : fallback;
function splitTime(hhmm: string): [string, string] {
const m = /^(\d{2}):(\d{2})$/.exec(hhmm);
if (!m) return ['00', '00'];
return [m[1], awayMinutes.includes(m[2]) ? m[2] : '00'];
}
function startEdit() {
const p = app.profile!;
dn = p.displayName;
tz = isOffsetZone(p.timeZone) && timezoneOffsets.includes(p.timeZone) ? p.timeZone : defaultTz();
awayStart = clockOr(p.awayStart, '00:00');
awayEnd = clockOr(p.awayEnd, '07:00');
[startH, startM] = splitTime(p.awayStart);
[endH, endM] = splitTime(p.awayEnd);
blockChat = p.blockChat;
blockFriendRequests = p.blockFriendRequests;
editing = true;
}
const awayStart = $derived(`${startH}:${startM}`);
const awayEnd = $derived(`${endH}:${endM}`);
const nameOk = $derived(validDisplayName(dn));
const awayOk = $derived(awayDurationOk(awayStart, awayEnd));
const formValid = $derived(nameOk && awayOk);
@@ -112,8 +122,16 @@
<fieldset class="away" class:invalid={!awayOk}>
<legend>{t('profile.awayWindow')}</legend>
<div class="times">
<label><span class="tlabel">{t('profile.from')}</span><input type="time" step="600" bind:value={awayStart} /></label>
<label><span class="tlabel">{t('profile.to')}</span><input type="time" step="600" bind:value={awayEnd} /></label>
<span class="tlabel">{t('profile.from')}</span>
<select bind:value={startH}>{#each awayHours as h (h)}<option>{h}</option>{/each}</select>
<span class="colon">:</span>
<select bind:value={startM}>{#each awayMinutes as m (m)}<option>{m}</option>{/each}</select>
</div>
<div class="times">
<span class="tlabel">{t('profile.to')}</span>
<select bind:value={endH}>{#each awayHours as h (h)}<option>{h}</option>{/each}</select>
<span class="colon">:</span>
<select bind:value={endM}>{#each awayMinutes as m (m)}<option>{m}</option>{/each}</select>
</div>
<p class="muted">{t('profile.awayHint')}</p>
</fieldset>
@@ -255,22 +273,17 @@
}
.times {
display: flex;
gap: 12px;
}
.times label {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.times input {
width: 100%;
box-sizing: border-box;
align-items: center;
gap: 8px;
}
.tlabel {
min-width: 2.5em;
color: var(--text-muted);
font-size: 0.85rem;
}
.colon {
font-weight: 700;
}
.check {
flex-direction: row !important;
align-items: center;