fix(toolbar,mobile): #428 landing follow-ups

Two pieces the #428 merge left out or lost:

- Spacing: the removed #shellCount stepper's 0.25rem margins were the only
  space between Run Shell and the case picker (the desktop .toolbar-group has
  gap: 0), so the two touched. The case picker now keeps the same 4px itself
  above 768px; at 768px and below mobile.css already spaces the group with gap.
- The phone case sheet's keyboard lift from #428's search commit. #488 shipped
  the sheet's search field without it, so on iOS (which does not shrink the
  layout viewport) the keyboard opens over the sheet and hides its own search
  box. KeyboardHandler now lifts an open sheet like the toolbar (translateY on
  phones, bottom on iPads), resetLayout() clears any sheet, and the list is
  capped while the keyboard is up so the search row and Create stay on screen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-08 20:01:45 +02:00
parent 9b28c277f0
commit aa8e06c162
4 changed files with 140 additions and 0 deletions
+20
View File
@@ -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 = '';
}
+12
View File
@@ -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));
}
+11
View File
@@ -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;
@@ -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<string, string>;
}
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('');
});
});