fix(#5701): keep the composer above iPad keyboards
On touch-primary devices (iPad Safari) the on-screen keyboard shrinks the visual viewport but not the layout viewport, so the bottom-pinned composer sits behind the keyboard. Compute a --keyboard-bottom-inset from visualViewport and pad the composer up by it; clear it when the keyboard closes / on non-touch / no-visualViewport. Reuses the existing visualViewport + pageshow hooks; no-op on desktop/fine-pointer. Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
This commit is contained in:
1 parent
021bc1280f
commit
ae8d0bf3fb
3 files changed
+83
-2
No files matched your search
@@ -149,6 +149,26 @@ function _isPhoneWidthViewport(){
|
||||
return window.matchMedia('(max-width: 640px)').matches;
|
||||
}
|
||||
|
||||
function _isTouchKeyboardViewport(){
|
||||
try{return matchMedia('(hover:none) and (pointer:coarse)').matches&&!_hasFinePointerCoexisting();}catch(_){return false;}
|
||||
}
|
||||
|
||||
function _syncKeyboardBottomInset(){
|
||||
const root=document.documentElement;
|
||||
if(!root) return;
|
||||
if(!window.visualViewport||!_isTouchKeyboardViewport()){
|
||||
root.style.removeProperty('--keyboard-bottom-inset');
|
||||
return;
|
||||
}
|
||||
const vv=window.visualViewport;
|
||||
const inset=Math.max(0,Math.ceil(window.innerHeight-(vv.height+vv.offsetTop)));
|
||||
if(inset>0){
|
||||
root.style.setProperty('--keyboard-bottom-inset',`${inset}px`);
|
||||
}else{
|
||||
root.style.removeProperty('--keyboard-bottom-inset');
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile PWA viewport reflow guard. When the on-screen keyboard / browser
|
||||
// chrome shows or hides, visualViewport (or a plain resize on browsers without
|
||||
// it) changes height without a layout invalidation, leaving the phone layout
|
||||
@@ -156,6 +176,7 @@ function _isPhoneWidthViewport(){
|
||||
// (which applies a cheap GPU-promotion transform under the @media(max-width:640px)
|
||||
// rule) forces a repaint, then we resync the workspace panel + sidebar aria.
|
||||
function _forceMobileViewportReflow(){
|
||||
_syncKeyboardBottomInset();
|
||||
if(!_isPhoneWidthViewport()) return;
|
||||
const layout=document.querySelector('.layout');
|
||||
if(!layout) return;
|
||||
@@ -2183,6 +2204,7 @@ window.addEventListener('resize',()=>{
|
||||
// URL-bar collapse fire visualViewport resize/scroll rather than window resize.
|
||||
// Debounce a reflow so the phone layout repaints against the new geometry.
|
||||
if(window.visualViewport){
|
||||
_syncKeyboardBottomInset();
|
||||
let _mobileViewportReflowTimer=0;
|
||||
const _scheduleMobileViewportReflow=()=>{
|
||||
if(_mobileViewportReflowTimer) clearTimeout(_mobileViewportReflowTimer);
|
||||
@@ -3391,6 +3413,7 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer;
|
||||
// chrome aren't left in the stale bfcache snapshot.
|
||||
window.addEventListener('pageshow', async (event) => {
|
||||
if (!event.persisted) return; // fresh loads are handled by the IIFE above
|
||||
_syncKeyboardBottomInset();
|
||||
const _srch = document.getElementById('sessionSearch');
|
||||
if (_srch) _srch.value = '';
|
||||
if (typeof syncSessionSearchClear === 'function') syncSessionSearchClear();
|
||||
|
||||
+5
-1
@@ -3062,7 +3062,7 @@
|
||||
.messages-inner{padding:12px 10px 20px;max-width:100%;overflow-x:clip;word-break:break-word;min-width:0;padding-left:max(10px,env(safe-area-inset-left,0));padding-right:max(10px,env(safe-area-inset-right,0));}
|
||||
.msg-body{padding-left:0;max-width:100%;}
|
||||
.msg-role{font-size:12px;}
|
||||
.composer-wrap{padding:8px 10px 12px!important;padding-left:max(10px,env(safe-area-inset-left,0))!important;padding-right:max(10px,env(safe-area-inset-right,0))!important;}
|
||||
.composer-wrap{padding:8px 10px calc(12px + var(--keyboard-bottom-inset, 0px))!important;padding-left:max(10px,env(safe-area-inset-left,0))!important;padding-right:max(10px,env(safe-area-inset-right,0))!important;}
|
||||
.composer-box{border-radius:12px;}
|
||||
.composer-box textarea{min-height:40px;}
|
||||
.composer-footer{padding:6px 8px 8px!important;gap:6px;flex-wrap:nowrap;align-items:center;}
|
||||
@@ -3432,6 +3432,10 @@ body.tts-enabled .msg-tts-btn{display:inline-flex;align-items:center;}
|
||||
.composer-wrap{padding:10px 20px 14px;position:relative;z-index:10;}
|
||||
.composer-wrap::before{content:"";position:absolute;left:0;right:0;bottom:100%;height:32px;background:linear-gradient(to bottom,transparent,var(--bg));pointer-events:none;}
|
||||
|
||||
@media (hover:none) and (pointer:coarse){
|
||||
.composer-wrap{padding-bottom:calc(14px + var(--keyboard-bottom-inset, 0px));}
|
||||
}
|
||||
|
||||
/* Cron status badges: pill shape refinement */
|
||||
.cron-status{border-radius:99px;font-size:10px;letter-spacing:.04em;}
|
||||
|
||||
|
||||
@@ -1048,7 +1048,7 @@ def test_safe_area_variables_available_for_pwa_shell():
|
||||
assert "--app-titlebar-safe-top:env(safe-area-inset-top" in CSS, (
|
||||
"CSS must expose env(safe-area-inset-top) through --app-titlebar-safe-top"
|
||||
)
|
||||
assert "padding:8px 10px 12px!important" in CSS, (
|
||||
assert "padding:8px 10px calc(12px + var(--keyboard-bottom-inset, 0px))!important" in CSS, (
|
||||
"Phone composer should keep the proven pre-cover-mode padding contract"
|
||||
)
|
||||
|
||||
@@ -1572,6 +1572,60 @@ def test_touch_device_inputs_meet_zoom_threshold():
|
||||
)
|
||||
|
||||
|
||||
def test_touch_keyboard_inset_uses_touch_primary_media_query():
|
||||
"""The keyboard inset path must key off touch-primary media queries."""
|
||||
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
assert "function _isTouchKeyboardViewport()" in boot_js, \
|
||||
"boot.js must define a touch-keyboard viewport predicate"
|
||||
assert "matchMedia('(hover:none) and (pointer:coarse)')" in boot_js or \
|
||||
'matchMedia("(hover:none) and (pointer:coarse)")' in boot_js, \
|
||||
"touch keyboard inset eligibility must use the hover:none + pointer:coarse media query"
|
||||
assert "any-pointer:fine" in boot_js, \
|
||||
"touch keyboard inset eligibility must exclude co-existing fine-pointer setups"
|
||||
assert "!_hasFinePointerCoexisting()" in boot_js, \
|
||||
"touch keyboard inset eligibility must skip hardware-keyboard/trackpad devices"
|
||||
|
||||
|
||||
def test_touch_keyboard_inset_writes_and_clears_css_variable():
|
||||
"""visualViewport geometry must write and clear the keyboard inset variable."""
|
||||
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
assert "setProperty('--keyboard-bottom-inset'" in boot_js, \
|
||||
"boot.js must write --keyboard-bottom-inset on touch keyboard viewport changes"
|
||||
assert "removeProperty('--keyboard-bottom-inset')" in boot_js, \
|
||||
"boot.js must clear --keyboard-bottom-inset when the inset is zero or ineligible"
|
||||
assert "Math.max(0,Math.ceil(window.innerHeight-(vv.height+vv.offsetTop)))" in boot_js, \
|
||||
"boot.js must compute the bottom inset from innerHeight and visualViewport geometry"
|
||||
|
||||
|
||||
def test_touch_keyboard_inset_primes_during_visual_viewport_setup():
|
||||
"""The existing visualViewport setup path must prime the inset immediately."""
|
||||
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
setup_start = boot_js.index("if(window.visualViewport){")
|
||||
setup_end = boot_js.index("window.visualViewport.addEventListener('resize'", setup_start)
|
||||
setup_block = boot_js[setup_start:setup_end]
|
||||
assert "_syncKeyboardBottomInset();" in setup_block, \
|
||||
"boot.js must sync the keyboard inset once during visualViewport setup"
|
||||
|
||||
|
||||
def test_touch_keyboard_inset_primes_on_pageshow_restore():
|
||||
"""BFCache restore must resync the inset before restore work continues."""
|
||||
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
pageshow_start = boot_js.index("window.addEventListener('pageshow'")
|
||||
pageshow_end = boot_js.index("const _srch = document.getElementById('sessionSearch');", pageshow_start)
|
||||
pageshow_block = boot_js[pageshow_start:pageshow_end]
|
||||
assert "_syncKeyboardBottomInset();" in pageshow_block, \
|
||||
"boot.js must sync the keyboard inset during pageshow restore"
|
||||
|
||||
|
||||
def test_touch_keyboard_inset_applies_to_composer_padding():
|
||||
"""Touch/coarse composer padding must consume the keyboard inset variable."""
|
||||
css_ns = re.sub(r'\s+', '', CSS)
|
||||
assert "@media(hover:none)and(pointer:coarse)" in css_ns, \
|
||||
"style.css must scope the keyboard inset padding to touch-primary viewports"
|
||||
assert ".composer-wrap{padding-bottom:calc(14px+var(--keyboard-bottom-inset,0px));}" in css_ns, \
|
||||
"style.css must add the keyboard inset to composer bottom padding"
|
||||
|
||||
|
||||
|
||||
# ── Sidebar tabs on mobile ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in new issue
Block a user