fix: improve Android tablet keyboard-safe layout
Use dynamic viewport sizing and keyboard-aware spacing to keep new-game inputs and navigation reachable in PWA landscape mode on Android tablets. Refs #30. Made-with: Cursor
This commit is contained in:
@@ -33,6 +33,35 @@ export default function App() {
|
|||||||
const validationModal = useValidationModal();
|
const validationModal = useValidationModal();
|
||||||
const completionModal = useCompletionModal();
|
const completionModal = useCompletionModal();
|
||||||
|
|
||||||
|
// Keep viewport height stable on Android tablets when virtual keyboard opens.
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
const updateViewportVars = () => {
|
||||||
|
const viewport = window.visualViewport;
|
||||||
|
const appHeight = viewport ? viewport.height : window.innerHeight;
|
||||||
|
const keyboardOffset = viewport
|
||||||
|
? Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
root.style.setProperty('--app-height', `${Math.round(appHeight)}px`);
|
||||||
|
root.style.setProperty('--keyboard-offset', `${Math.round(keyboardOffset)}px`);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateViewportVars();
|
||||||
|
window.addEventListener('resize', updateViewportVars);
|
||||||
|
window.addEventListener('orientationchange', updateViewportVars);
|
||||||
|
window.visualViewport?.addEventListener('resize', updateViewportVars);
|
||||||
|
window.visualViewport?.addEventListener('scroll', updateViewportVars);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', updateViewportVars);
|
||||||
|
window.removeEventListener('orientationchange', updateViewportVars);
|
||||||
|
window.visualViewport?.removeEventListener('resize', updateViewportVars);
|
||||||
|
window.visualViewport?.removeEventListener('scroll', updateViewportVars);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Game lifecycle handlers
|
// Game lifecycle handlers
|
||||||
const handleCreateGame = useCallback(async (gameData: any) => {
|
const handleCreateGame = useCallback(async (gameData: any) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 100vh;
|
min-height: var(--app-height);
|
||||||
display: none;
|
display: none;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(100%);
|
transform: translateX(100%);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
.screen-content {
|
.screen-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
min-height: var(--app-height);
|
||||||
padding: var(--space-lg);
|
padding: var(--space-lg);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 100vh;
|
min-height: var(--app-height);
|
||||||
display: none;
|
display: none;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(100%);
|
transform: translateX(100%);
|
||||||
@@ -145,8 +145,10 @@
|
|||||||
|
|
||||||
.form-content {
|
.form-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
padding: 0 var(--space-lg);
|
padding: 0 var(--space-lg);
|
||||||
|
padding-bottom: var(--space-md);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -154,7 +156,12 @@
|
|||||||
|
|
||||||
.form-footer {
|
.form-footer {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: var(--space-lg);
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: var(--color-surface);
|
||||||
|
padding: var(--space-md) var(--space-lg) calc(var(--space-lg) + var(--keyboard-offset)) var(--space-lg);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
.progress-indicator {
|
.progress-indicator {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -210,7 +217,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: var(--space-xxl);
|
margin-top: var(--space-lg);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
gap: var(--space-lg);
|
gap: var(--space-lg);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,8 +111,15 @@ export const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
autoCapitalize="words"
|
||||||
|
spellCheck={false}
|
||||||
|
enterKeyHint="next"
|
||||||
aria-label="Name Spieler 1"
|
aria-label="Name Spieler 1"
|
||||||
aria-describedby="player1-help"
|
aria-describedby="player1-help"
|
||||||
|
onFocus={(e) => {
|
||||||
|
const target = e.currentTarget as HTMLInputElement;
|
||||||
|
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
fontSize: UI_CONSTANTS.INPUT_FONT_SIZE,
|
fontSize: UI_CONSTANTS.INPUT_FONT_SIZE,
|
||||||
minHeight: UI_CONSTANTS.INPUT_MIN_HEIGHT,
|
minHeight: UI_CONSTANTS.INPUT_MIN_HEIGHT,
|
||||||
|
|||||||
@@ -70,7 +70,14 @@ export const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue
|
|||||||
setPlayer2(target.value);
|
setPlayer2(target.value);
|
||||||
}}
|
}}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
autoCapitalize="words"
|
||||||
|
spellCheck={false}
|
||||||
|
enterKeyHint="next"
|
||||||
aria-label="Name Spieler 2"
|
aria-label="Name Spieler 2"
|
||||||
|
onFocus={(e) => {
|
||||||
|
const target = e.currentTarget as HTMLInputElement;
|
||||||
|
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
|
}}
|
||||||
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
|
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -67,7 +67,14 @@ export const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue
|
|||||||
setPlayer3(target.value);
|
setPlayer3(target.value);
|
||||||
}}
|
}}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
autoCapitalize="words"
|
||||||
|
spellCheck={false}
|
||||||
|
enterKeyHint="done"
|
||||||
aria-label="Name Spieler 3"
|
aria-label="Name Spieler 3"
|
||||||
|
onFocus={(e) => {
|
||||||
|
const target = e.currentTarget as HTMLInputElement;
|
||||||
|
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
|
}}
|
||||||
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
|
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
.layout {
|
.layout {
|
||||||
height: 100vh;
|
height: var(--app-height);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import App from "../components/App";
|
|||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
|
||||||
<title>BSC Score - Pool Scoring App</title>
|
<title>BSC Score - Pool Scoring App</title>
|
||||||
<meta name="description" content="Professional pool/billiards scoring application for tournaments and casual games">
|
<meta name="description" content="Professional pool/billiards scoring application for tournaments and casual games">
|
||||||
|
|
||||||
|
|||||||
+10
-7
@@ -3,17 +3,13 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
-webkit-touch-callout: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-khtml-user-select: none;
|
|
||||||
-moz-user-select: none;
|
|
||||||
-ms-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Design system tokens */
|
/* Design system tokens */
|
||||||
:root {
|
:root {
|
||||||
|
--app-height: 100dvh;
|
||||||
|
--keyboard-offset: 0px;
|
||||||
/* Colors */
|
/* Colors */
|
||||||
--color-primary: #ff9800;
|
--color-primary: #ff9800;
|
||||||
--color-primary-hover: #ffa726;
|
--color-primary-hover: #ffa726;
|
||||||
@@ -87,7 +83,7 @@
|
|||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -98,6 +94,13 @@ body {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
min-height: var(--app-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
button, .btn, .button {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Improved input styling for better tablet experience */
|
/* Improved input styling for better tablet experience */
|
||||||
|
|||||||
Reference in New Issue
Block a user