diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index e06e4c60..fb22923a 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -410,6 +410,12 @@ const KeyboardHandler = { const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight); const accessoryBar = document.querySelector('.keyboard-accessory-bar'); + // The mobile case picker is a third position:fixed bottom-anchored + // surface, and since it gained a search field the keyboard can open over + // it. iOS does not shrink the layout viewport, so an unlifted sheet sits + // BEHIND the keyboard with its own search box out of sight. + const caseSheet = document.querySelector('.mobile-case-picker.active .mobile-case-picker-sheet'); + if (isSmallMedium) { // Phones/small tablets: toolbar and accessory bar are position:fixed // via CSS. Use translateY to lift them above the keyboard. @@ -426,6 +432,9 @@ const KeyboardHandler = { if (accessoryBar) { accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : ''; } + if (caseSheet) { + caseSheet.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : ''; + } if (main && keyboardHeight > 0) { const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0; main.style.paddingBottom = `${84 + cjkInputHeight}px`; @@ -436,6 +445,9 @@ const KeyboardHandler = { if (accessoryBar) { accessoryBar.style.bottom = `${keyboardHeight}px`; } + if (caseSheet) { + caseSheet.style.bottom = `${keyboardHeight}px`; + } } // CJK textarea positioning (always position:fixed on touch devices). @@ -464,6 +476,10 @@ const KeyboardHandler = { const accessoryBar = document.querySelector('.keyboard-accessory-bar'); const cjkInput = document.getElementById('cjkInput'); const main = document.querySelector('.main'); + // Not scoped to `.active`, unlike the lift above: a sheet closed while the + // keyboard was still up must still have its inline offset cleared, or the + // next open slides in already displaced. + const caseSheet = document.querySelector('.mobile-case-picker-sheet'); if (toolbar) { toolbar.style.transform = ''; @@ -476,6 +492,10 @@ const KeyboardHandler = { cjkInput.style.transform = ''; cjkInput.style.bottom = ''; } + if (caseSheet) { + caseSheet.style.transform = ''; + caseSheet.style.bottom = ''; + } if (main) { main.style.paddingBottom = ''; } diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index a98490e0..a96e3b20 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -2308,6 +2308,18 @@ html.mobile-init .file-browser-panel { font-size: 1.5rem; } + /* With the keyboard up the sheet is lifted above it (mobile-handlers.js), so + what is left to fit is much shorter than 80dvh of the layout viewport. Cap + the list rather than the sheet, so the search row and the Create button + stay on screen and only the rows scroll. */ + .keyboard-visible .mobile-case-picker-sheet { + max-height: 45vh; + } + + .keyboard-visible .mobile-case-picker-body { + max-height: 28vh; + } + .mobile-case-picker-footer { padding-bottom: calc(12px + var(--safe-area-bottom)); } diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 05e57339..44e8d3ab 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -6644,6 +6644,17 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { margin: 0 0.25rem; } +/* Run Shell used to be followed by a second instance stepper whose 0.25rem + margins were the only space before the case picker (the desktop + .toolbar-group has gap: 0). That stepper is gone (#428), so the case picker + keeps the same 4px itself. At 768px and below mobile.css spaces the group + with a gap instead. */ +@media (min-width: 769px) { + .toolbar-group > .case-select-group { + margin-left: 0.25rem; + } +} + .tab-count-btn { display: flex; align-items: center; diff --git a/test/mobile-case-sheet-keyboard-lift.test.ts b/test/mobile-case-sheet-keyboard-lift.test.ts new file mode 100644 index 00000000..8ed188b6 --- /dev/null +++ b/test/mobile-case-sheet-keyboard-lift.test.ts @@ -0,0 +1,97 @@ +// Port: none (pure logic in a vm context — no browser, no server). +// +// The phone "Select Case" bottom sheet (#mobileCasePickerModal) has a search +// field (#488), so the on-screen keyboard can open over it. The sheet is a third +// `position: fixed` bottom-anchored surface beside the toolbar and the keyboard +// accessory bar, and iOS does not shrink the layout viewport for the keyboard, so +// an unlifted sheet sits BEHIND the keyboard with its own search box out of +// sight. KeyboardHandler therefore lifts an OPEN sheet the same way it lifts the +// toolbar (translateY on phones, `bottom` on iPads), and resetLayout() clears the +// offset on ANY sheet, open or not, so a sheet closed with the keyboard still up +// does not slide in already displaced next time (#428). +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it } from 'vitest'; + +const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/mobile-handlers.js'), 'utf8'); + +interface Styled { + style: Record; +} + +function loadHandler(opts: { width: number; layoutHeight: number; visualHeight: number; sheetOpen: boolean }) { + const el = (): Styled => ({ style: { transform: '', bottom: '', paddingBottom: '' } }); + const sheet = el(); + const toolbar = el(); + const accessoryBar = el(); + const main = el(); + const context = vm.createContext({ + console, + navigator: { userAgent: 'Mozilla/5.0 (iPhone) Mobile', maxTouchPoints: 5 }, + window: { + innerWidth: opts.width, + innerHeight: opts.layoutHeight, + visualViewport: { height: opts.visualHeight, offsetTop: 0 }, + addEventListener: () => {}, + matchMedia: () => ({ matches: true }), + scrollTo: () => {}, + }, + document: { + body: { classList: { add: () => {}, remove: () => {}, contains: () => false } }, + addEventListener: () => {}, + getElementById: () => null, + querySelector: (sel: string) => { + if (sel === '.mobile-case-picker.active .mobile-case-picker-sheet') return opts.sheetOpen ? sheet : null; + if (sel === '.mobile-case-picker-sheet') return sheet; + if (sel === '.toolbar') return toolbar; + if (sel === '.keyboard-accessory-bar') return accessoryBar; + if (sel === '.main') return main; + return null; + }, + }, + setTimeout: () => 1, + clearTimeout: () => {}, + }); + vm.runInContext(`${SOURCE}\nglobalThis.__KH = KeyboardHandler;`, context, { filename: 'mobile-handlers.js' }); + const handler = (context as { __KH: any }).__KH; + handler.keyboardVisible = true; + handler.initialViewportHeight = opts.layoutHeight; + return { handler, sheet, toolbar }; +} + +describe('mobile case sheet keyboard lift', () => { + it('lifts an open sheet above the keyboard on a phone, by the same offset as the toolbar', () => { + const { handler, sheet, toolbar } = loadHandler({ + width: 390, + layoutHeight: 844, + visualHeight: 500, + sheetOpen: true, + }); + handler.updateLayoutForKeyboard(); + expect(sheet.style.transform).toBe('translateY(-344px)'); + expect(sheet.style.transform).toBe(toolbar.style.transform); + }); + + it('positions an open sheet by `bottom` on an iPad-sized screen', () => { + const { handler, sheet } = loadHandler({ width: 1024, layoutHeight: 1366, visualHeight: 966, sheetOpen: true }); + handler.updateLayoutForKeyboard(); + expect(sheet.style.bottom).toBe('400px'); + expect(sheet.style.transform).toBe(''); + }); + + it('leaves a closed sheet alone while the keyboard is up', () => { + const { handler, sheet } = loadHandler({ width: 390, layoutHeight: 844, visualHeight: 500, sheetOpen: false }); + handler.updateLayoutForKeyboard(); + expect(sheet.style.transform).toBe(''); + }); + + it('resetLayout clears the offset even on a sheet that is no longer open', () => { + const { handler, sheet } = loadHandler({ width: 390, layoutHeight: 844, visualHeight: 500, sheetOpen: false }); + sheet.style.transform = 'translateY(-344px)'; + sheet.style.bottom = '400px'; + handler.resetLayout(); + expect(sheet.style.transform).toBe(''); + expect(sheet.style.bottom).toBe(''); + }); +});