Spaces:
Running
Mobile keyboard: stop estimating a height nobody can measure
Browse filesWhen the browser reports no keyboard geometry — a cross-origin frame on mobile
Safari, which is how the Hub page serves a Space — the app assumed a keyboard ate
46% of the frame and shrank itself to the remaining 54%.
A guess in the safe direction is still a guess, and this one had a visible cost:
the abandoned strip does not stay hidden behind the keyboard. The browser
scroll-reveals a focused field, dragging that strip back into view — body's --bg
against the reader's --panel, sized by a constant rather than by the keyboard.
That is the blank band under the reader's composer. It went unnoticed for as long
as it did because in terminal mode the strip is unused black.
So: report the viewport we can see, and stop. Nothing here knows how tall a
keyboard is, and the engine that does already scrolls a focused field into view —
the same scroll-into-view that keeps xterm's pinned helper textarea above the
keyboard today. Where real geometry exists (a direct browser's visualViewport,
Chromium's virtualKeyboard) nothing changes.
Deletes the ratio, the fallback timer, the embedded-touch-layout probe, the
`focus-fallback` layout mode and a write-only `focusedInput`: 58 lines out, 24
in. `focusBaseline` stays — a keyboard is still detected as the SHRINK from it.
This is also the experiment for the remaining unknown: if the band survives
without a clamp to cause it, the cause is WebKit displacing a fixed subtree on
scroll-to-reveal, which is a different fix in a different place.
tsc clean, exchanges ok, production build succeeds. Needs a phone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- web/src/App.tsx +24 -58
|
@@ -117,10 +117,13 @@ export default function App() {
|
|
| 117 |
|
| 118 |
// Track the visual viewport so the mobile layout can sit above the on-screen
|
| 119 |
// keyboard (which shrinks visualViewport but not the layout viewport on iOS).
|
| 120 |
-
// The CSS variables pin the app to that viewport's exact rectangle.
|
| 121 |
-
//
|
| 122 |
-
//
|
| 123 |
-
//
|
|
|
|
|
|
|
|
|
|
| 124 |
useEffect(() => {
|
| 125 |
const vv = window.visualViewport;
|
| 126 |
type VirtualKeyboardLike = EventTarget & { boundingRect?: DOMRectReadOnly };
|
|
@@ -133,11 +136,10 @@ export default function App() {
|
|
| 133 |
};
|
| 134 |
const root = document.documentElement;
|
| 135 |
const keyboardSignalThreshold = 80;
|
| 136 |
-
|
| 137 |
-
|
|
|
|
| 138 |
let focusBaseline: ViewportBaseline | null = null;
|
| 139 |
-
let focusFallback = false;
|
| 140 |
-
let focusFallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
| 141 |
|
| 142 |
const acceptsKeyboardInput = (target: Element | null): target is HTMLElement => {
|
| 143 |
if (!(target instanceof HTMLElement)) return false;
|
|
@@ -147,9 +149,6 @@ export default function App() {
|
|
| 147 |
return !['button', 'checkbox', 'color', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit']
|
| 148 |
.includes(target.type);
|
| 149 |
};
|
| 150 |
-
const embeddedTouchLayout = () => embedded
|
| 151 |
-
&& window.matchMedia('(max-width: 720px)').matches
|
| 152 |
-
&& (navigator.maxTouchPoints > 0 || window.matchMedia('(pointer: coarse)').matches);
|
| 153 |
const captureViewport = (): ViewportBaseline => ({
|
| 154 |
width: vv?.width ?? document.documentElement.clientWidth,
|
| 155 |
height: vv?.height ?? window.innerHeight,
|
|
@@ -178,20 +177,18 @@ export default function App() {
|
|
| 178 |
if (keyboardRect && keyboardRect.height > 0 && keyboardRect.top > top) {
|
| 179 |
height = Math.min(height, keyboardRect.top - top);
|
| 180 |
}
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
delete root.dataset.keyboardLayout;
|
| 194 |
-
}
|
| 195 |
root.style.setProperty('--vvw', `${Math.round(width)}px`);
|
| 196 |
root.style.setProperty('--vvh', `${Math.round(height)}px`);
|
| 197 |
root.style.setProperty('--vv-top', `${Math.round(top)}px`);
|
|
@@ -223,54 +220,24 @@ export default function App() {
|
|
| 223 |
focusTimers.add(timer);
|
| 224 |
}
|
| 225 |
};
|
| 226 |
-
const scheduleEmbeddedFallback = () => {
|
| 227 |
-
if (focusFallbackTimer) clearTimeout(focusFallbackTimer);
|
| 228 |
-
focusFallbackTimer = null;
|
| 229 |
-
if (!embeddedTouchLayout() || !acceptsKeyboardInput(document.activeElement)) return;
|
| 230 |
-
focusFallbackTimer = setTimeout(() => {
|
| 231 |
-
focusFallbackTimer = null;
|
| 232 |
-
if (document.activeElement === focusedInput && !hasKeyboardGeometry()) {
|
| 233 |
-
focusFallback = true;
|
| 234 |
-
apply();
|
| 235 |
-
}
|
| 236 |
-
}, 500);
|
| 237 |
-
};
|
| 238 |
const onFocusIn = (event: FocusEvent) => {
|
| 239 |
const target = event.target instanceof Element ? event.target : null;
|
| 240 |
-
if (acceptsKeyboardInput(target))
|
| 241 |
-
focusedInput = target;
|
| 242 |
-
focusBaseline = captureViewport();
|
| 243 |
-
focusFallback = false;
|
| 244 |
-
stabilizeFocus();
|
| 245 |
-
scheduleEmbeddedFallback();
|
| 246 |
-
return;
|
| 247 |
-
}
|
| 248 |
stabilizeFocus();
|
| 249 |
};
|
| 250 |
const onFocusOut = () => {
|
| 251 |
-
if (focusFallbackTimer) clearTimeout(focusFallbackTimer);
|
| 252 |
-
focusFallbackTimer = null;
|
| 253 |
const timer = setTimeout(() => {
|
| 254 |
focusTimers.delete(timer);
|
| 255 |
if (!acceptsKeyboardInput(document.activeElement)) {
|
| 256 |
-
focusedInput = null;
|
| 257 |
focusBaseline = null;
|
| 258 |
-
focusFallback = false;
|
| 259 |
apply();
|
| 260 |
}
|
| 261 |
}, 0);
|
| 262 |
focusTimers.add(timer);
|
| 263 |
};
|
| 264 |
const onOrientationChange = () => {
|
| 265 |
-
if (
|
| 266 |
-
focusFallbackTimer = null;
|
| 267 |
-
focusFallback = false;
|
| 268 |
-
if (acceptsKeyboardInput(document.activeElement)) {
|
| 269 |
-
focusedInput = document.activeElement;
|
| 270 |
-
focusBaseline = captureViewport();
|
| 271 |
-
}
|
| 272 |
stabilizeFocus();
|
| 273 |
-
scheduleEmbeddedFallback();
|
| 274 |
};
|
| 275 |
apply();
|
| 276 |
vv?.addEventListener('resize', onViewportChange);
|
|
@@ -284,7 +251,6 @@ export default function App() {
|
|
| 284 |
return () => {
|
| 285 |
for (const timer of settleTimers) clearTimeout(timer);
|
| 286 |
for (const timer of focusTimers) clearTimeout(timer);
|
| 287 |
-
if (focusFallbackTimer) clearTimeout(focusFallbackTimer);
|
| 288 |
vv?.removeEventListener('resize', onViewportChange);
|
| 289 |
vv?.removeEventListener('scroll', onViewportChange);
|
| 290 |
vv?.removeEventListener('scrollend', onViewportChange);
|
|
|
|
| 117 |
|
| 118 |
// Track the visual viewport so the mobile layout can sit above the on-screen
|
| 119 |
// keyboard (which shrinks visualViewport but not the layout viewport on iOS).
|
| 120 |
+
// The CSS variables pin the app to that viewport's exact rectangle.
|
| 121 |
+
//
|
| 122 |
+
// Where there is no signal — the Hub page embeds the app in a cross-origin
|
| 123 |
+
// iframe, and mobile Safari leaves that child viewport unchanged when its
|
| 124 |
+
// keyboard opens — the app reports the viewport it can see and stops there.
|
| 125 |
+
// It does not estimate one. Nothing here knows how tall a keyboard is, and
|
| 126 |
+
// the browser that does already scrolls a focused field into view.
|
| 127 |
useEffect(() => {
|
| 128 |
const vv = window.visualViewport;
|
| 129 |
type VirtualKeyboardLike = EventTarget & { boundingRect?: DOMRectReadOnly };
|
|
|
|
| 136 |
};
|
| 137 |
const root = document.documentElement;
|
| 138 |
const keyboardSignalThreshold = 80;
|
| 139 |
+
// The viewport as it was before a field took focus — the only thing left
|
| 140 |
+
// that needs remembering, because a keyboard is detected as the SHRINK from
|
| 141 |
+
// it (hasKeyboardGeometry), not as an absolute height.
|
| 142 |
let focusBaseline: ViewportBaseline | null = null;
|
|
|
|
|
|
|
| 143 |
|
| 144 |
const acceptsKeyboardInput = (target: Element | null): target is HTMLElement => {
|
| 145 |
if (!(target instanceof HTMLElement)) return false;
|
|
|
|
| 149 |
return !['button', 'checkbox', 'color', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit']
|
| 150 |
.includes(target.type);
|
| 151 |
};
|
|
|
|
|
|
|
|
|
|
| 152 |
const captureViewport = (): ViewportBaseline => ({
|
| 153 |
width: vv?.width ?? document.documentElement.clientWidth,
|
| 154 |
height: vv?.height ?? window.innerHeight,
|
|
|
|
| 177 |
if (keyboardRect && keyboardRect.height > 0 && keyboardRect.top > top) {
|
| 178 |
height = Math.min(height, keyboardRect.top - top);
|
| 179 |
}
|
| 180 |
+
// When the browser reports no keyboard geometry — a cross-origin frame on
|
| 181 |
+
// mobile Safari, which the Hub page is — the app does NOT invent a height.
|
| 182 |
+
// It used to assume a keyboard ate 46% and shrink to fit, and a guess that
|
| 183 |
+
// is wrong in the safe direction is still wrong: the abandoned strip does
|
| 184 |
+
// not stay hidden behind the keyboard, because the browser scroll-reveals
|
| 185 |
+
// a focused field and drags it back into view. That strip is the blank
|
| 186 |
+
// band under the reader's composer. Leaving the viewport alone hands the
|
| 187 |
+
// job to the engine that can actually see the keyboard — the same
|
| 188 |
+
// scroll-into-view that already keeps xterm's pinned helper textarea
|
| 189 |
+
// above it.
|
| 190 |
+
if (hasKeyboardGeometry()) root.dataset.keyboardLayout = 'browser-geometry';
|
| 191 |
+
else delete root.dataset.keyboardLayout;
|
|
|
|
|
|
|
| 192 |
root.style.setProperty('--vvw', `${Math.round(width)}px`);
|
| 193 |
root.style.setProperty('--vvh', `${Math.round(height)}px`);
|
| 194 |
root.style.setProperty('--vv-top', `${Math.round(top)}px`);
|
|
|
|
| 220 |
focusTimers.add(timer);
|
| 221 |
}
|
| 222 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
const onFocusIn = (event: FocusEvent) => {
|
| 224 |
const target = event.target instanceof Element ? event.target : null;
|
| 225 |
+
if (acceptsKeyboardInput(target)) focusBaseline = captureViewport();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
stabilizeFocus();
|
| 227 |
};
|
| 228 |
const onFocusOut = () => {
|
|
|
|
|
|
|
| 229 |
const timer = setTimeout(() => {
|
| 230 |
focusTimers.delete(timer);
|
| 231 |
if (!acceptsKeyboardInput(document.activeElement)) {
|
|
|
|
| 232 |
focusBaseline = null;
|
|
|
|
| 233 |
apply();
|
| 234 |
}
|
| 235 |
}, 0);
|
| 236 |
focusTimers.add(timer);
|
| 237 |
};
|
| 238 |
const onOrientationChange = () => {
|
| 239 |
+
if (acceptsKeyboardInput(document.activeElement)) focusBaseline = captureViewport();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
stabilizeFocus();
|
|
|
|
| 241 |
};
|
| 242 |
apply();
|
| 243 |
vv?.addEventListener('resize', onViewportChange);
|
|
|
|
| 251 |
return () => {
|
| 252 |
for (const timer of settleTimers) clearTimeout(timer);
|
| 253 |
for (const timer of focusTimers) clearTimeout(timer);
|
|
|
|
| 254 |
vv?.removeEventListener('resize', onViewportChange);
|
| 255 |
vv?.removeEventListener('scroll', onViewportChange);
|
| 256 |
vv?.removeEventListener('scrollend', onViewportChange);
|