fix(offline): hotseat form — natural scroll + focus-into-view (keyboard)
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 1m6s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m38s
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 1m6s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m38s
The nested scroll region regressed the keyboard behaviour (a double scroll: the whole screen scrolled with a huge blank area under the Start button, and a focused bottom row hid behind the keyboard). Drop it: the hotseat form now flows naturally and scrolls with the screen's own scroll (.page.scrollform: no forced full height, no pinned button), and a name input scrolls itself into view (centred above the keyboard) on focus.
This commit is contained in:
@@ -232,6 +232,14 @@
|
||||
deleteRow = null;
|
||||
}
|
||||
|
||||
// Centre a focused name input above the soft keyboard: the keyboard shrinks the screen (--vvh),
|
||||
// so wait a beat for it to open + the layout to settle, then scroll the field into view (the
|
||||
// default focus-scroll leaves a bottom row hidden behind the keyboard).
|
||||
function bringIntoView(e: FocusEvent): void {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
setTimeout(() => el.scrollIntoView({ block: 'center' }), 300);
|
||||
}
|
||||
|
||||
function setHostPlays(yes: boolean): void {
|
||||
askHostPlays = false;
|
||||
if (!yes) return;
|
||||
@@ -278,7 +286,10 @@
|
||||
</script>
|
||||
|
||||
<Screen title={t('new.title')} back="/">
|
||||
<div class="page">
|
||||
<!-- The hotseat form flows naturally and scrolls with the screen's own scroll (scrollform: no
|
||||
forced full height, no pinned button) so a focused name input's soft keyboard just scrolls the
|
||||
page rather than fighting a nested scroll region. -->
|
||||
<div class="page" class:scrollform={mode === 'friends' && offlineMode.active}>
|
||||
<!-- The auto/friends selector. Online it is hidden for guests (no friends to invite). Offline
|
||||
it is always shown: "quick" is the local vs_ai flow, "with friends" is the local
|
||||
pass-and-play (hotseat) flow. -->
|
||||
@@ -332,82 +343,77 @@
|
||||
>{t('new.start')}</button>
|
||||
{:else if offlineMode.active}
|
||||
<!-- Offline pass-and-play (hotseat): a device-local 2-4 human game. The host sets a master
|
||||
PIN first (it gates the roster); each seat may add its own PIN. The scrollable region +
|
||||
the pinned Start button mirror the friends form, so a name input's soft keyboard shrinks
|
||||
the region (no full-height spacer that would paint a blank block behind the keyboard). -->
|
||||
<div class="fg">
|
||||
<div class="hs-scroll">
|
||||
<div class="hostpin">
|
||||
<span class="ftitle">{t('hotseat.hostPin')}</span>
|
||||
<button class="plink" onclick={() => (pad = hostPin ? { kind: 'host-change' } : { kind: 'host-set' })}>
|
||||
{hostPin ? t('hotseat.changePin') : t('hotseat.setPin')}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Variant plaques + the multiple-words toggle, identical to Quick Match. -->
|
||||
<div class="variants">
|
||||
{#each variants as v (v.id)}
|
||||
<button class="variant" class:selected={inviteVariant === v.id} onclick={() => (inviteVariant = v.id)}>
|
||||
<span class="vmain">
|
||||
<span class="vname">{t(v.label)}</span>
|
||||
{#if VARIANT_FLAG[v.id]}
|
||||
<span class="vflag">{VARIANT_FLAG[v.id]}</span>
|
||||
{:else}
|
||||
<img class="vflag-img" src="flag-ussr.svg" alt="" />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="vrules">{t(VARIANT_RULES[v.id])}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if variants.some((v) => supportsMultipleWordsToggle(v.id))}
|
||||
<label class="toggle">
|
||||
<span>{t('new.multipleWordsPerTurn')}</span>
|
||||
<input type="checkbox" bind:checked={multipleWords} />
|
||||
</label>
|
||||
{/if}
|
||||
<div class="roster">
|
||||
{#each rows as row, i (i)}
|
||||
<div class="prow">
|
||||
<input
|
||||
class="pname"
|
||||
class:invalid={row.name.trim() !== '' && !validDisplayName(row.name)}
|
||||
bind:value={row.name}
|
||||
disabled={!hostPin}
|
||||
placeholder={t('hotseat.playerName')}
|
||||
maxlength="40"
|
||||
oninput={() => (row.committed = commitName(row.name, row.committed))}
|
||||
onblur={() => (row.name = row.committed)}
|
||||
/>
|
||||
<button class="plink" disabled={!hostPin || row.committed === ''} onclick={() => openSeatPin(i)}>
|
||||
{row.pin ? t('hotseat.changePin') : t('hotseat.setPin')}
|
||||
</button>
|
||||
{#if rows.length > 2}
|
||||
{#if deleteRow === i}
|
||||
<button class="prow-del" aria-label={t('hotseat.removePlayer')} onclick={() => removeRow(i)}>❌</button>
|
||||
{/if}
|
||||
<button
|
||||
class="pkebab"
|
||||
class:armed={deleteRow === i}
|
||||
disabled={!hostPin}
|
||||
aria-label={t('hotseat.removePlayer')}
|
||||
onclick={() => onKebab(i)}
|
||||
>⋮</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if rows.length < 4}
|
||||
<button class="addrow" disabled={!hostPin} onclick={addRow}>
|
||||
+ {t('hotseat.addPlayer')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="invite"
|
||||
disabled={!hostPin || !inviteVariant || !rosterReady(rows) || starting}
|
||||
onclick={startHotseat}
|
||||
>{t('new.start')}</button>
|
||||
PIN first (it gates the roster); each seat may add its own PIN. -->
|
||||
<div class="hostpin">
|
||||
<span class="ftitle">{t('hotseat.hostPin')}</span>
|
||||
<button class="plink" onclick={() => (pad = hostPin ? { kind: 'host-change' } : { kind: 'host-set' })}>
|
||||
{hostPin ? t('hotseat.changePin') : t('hotseat.setPin')}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Variant plaques + the multiple-words toggle, identical to Quick Match. -->
|
||||
<div class="variants">
|
||||
{#each variants as v (v.id)}
|
||||
<button class="variant" class:selected={inviteVariant === v.id} onclick={() => (inviteVariant = v.id)}>
|
||||
<span class="vmain">
|
||||
<span class="vname">{t(v.label)}</span>
|
||||
{#if VARIANT_FLAG[v.id]}
|
||||
<span class="vflag">{VARIANT_FLAG[v.id]}</span>
|
||||
{:else}
|
||||
<img class="vflag-img" src="flag-ussr.svg" alt="" />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="vrules">{t(VARIANT_RULES[v.id])}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if variants.some((v) => supportsMultipleWordsToggle(v.id))}
|
||||
<label class="toggle">
|
||||
<span>{t('new.multipleWordsPerTurn')}</span>
|
||||
<input type="checkbox" bind:checked={multipleWords} />
|
||||
</label>
|
||||
{/if}
|
||||
<div class="roster">
|
||||
{#each rows as row, i (i)}
|
||||
<div class="prow">
|
||||
<input
|
||||
class="pname"
|
||||
class:invalid={row.name.trim() !== '' && !validDisplayName(row.name)}
|
||||
bind:value={row.name}
|
||||
disabled={!hostPin}
|
||||
placeholder={t('hotseat.playerName')}
|
||||
maxlength="40"
|
||||
onfocus={bringIntoView}
|
||||
oninput={() => (row.committed = commitName(row.name, row.committed))}
|
||||
onblur={() => (row.name = row.committed)}
|
||||
/>
|
||||
<button class="plink" disabled={!hostPin || row.committed === ''} onclick={() => openSeatPin(i)}>
|
||||
{row.pin ? t('hotseat.changePin') : t('hotseat.setPin')}
|
||||
</button>
|
||||
{#if rows.length > 2}
|
||||
{#if deleteRow === i}
|
||||
<button class="prow-del" aria-label={t('hotseat.removePlayer')} onclick={() => removeRow(i)}>❌</button>
|
||||
{/if}
|
||||
<button
|
||||
class="pkebab"
|
||||
class:armed={deleteRow === i}
|
||||
disabled={!hostPin}
|
||||
aria-label={t('hotseat.removePlayer')}
|
||||
onclick={() => onKebab(i)}
|
||||
>⋮</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if rows.length < 4}
|
||||
<button class="addrow" disabled={!hostPin} onclick={addRow}>
|
||||
+ {t('hotseat.addPlayer')}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="invite"
|
||||
disabled={!hostPin || !inviteVariant || !rosterReady(rows) || starting}
|
||||
onclick={startHotseat}
|
||||
>{t('new.start')}</button>
|
||||
{:else if friends.length === 0}
|
||||
<p class="subtitle">{t('new.noFriends')}</p>
|
||||
{:else}
|
||||
@@ -486,6 +492,12 @@
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* The hotseat form: natural height (grows past the viewport → the screen's own scroll takes over)
|
||||
with no full-height pinning, so a focused name input just scrolls the page above the keyboard. */
|
||||
.page.scrollform {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
}
|
||||
.subtitle {
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
@@ -680,17 +692,6 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
/* --- offline hotseat roster --- */
|
||||
/* The scrollable region (host PIN + variants + roster), pinned above the Start button — mirrors
|
||||
the friends form so a focused name input's soft keyboard shrinks this region (via --vvh on the
|
||||
screen) instead of leaving a full-height blank spacer painted behind the keyboard. */
|
||||
.hs-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.hostpin {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user