diff --git a/.changeset/fix-tray-keyboard-inset.md b/.changeset/fix-tray-keyboard-inset.md new file mode 100644 index 00000000..ed4344d0 --- /dev/null +++ b/.changeset/fix-tray-keyboard-inset.md @@ -0,0 +1,5 @@ +--- +'@luke-ui/react': patch +--- + +Fix the mobile tray sitting behind the iOS on-screen keyboard. diff --git a/packages/@luke-ui/react/src/combobox-field/combobox-field.browser.test.tsx b/packages/@luke-ui/react/src/combobox-field/combobox-field.browser.test.tsx new file mode 100644 index 00000000..dc45df22 --- /dev/null +++ b/packages/@luke-ui/react/src/combobox-field/combobox-field.browser.test.tsx @@ -0,0 +1,71 @@ +import { afterEach, expect, test } from 'vite-plus/test'; +import { page, userEvent } from 'vite-plus/test/context'; +import { + comboboxTrayKeyboardInsetVar, + comboboxTrayViewportHeightVar, +} from '../recipes/combobox.css.js'; +import { cleanupVisual, renderVisual } from '../test-utils/render-visual.js'; +import { ComboboxField } from './index.js'; +import { ComboboxItem } from './primitive/index.js'; + +type CountryItem = { + id: string; + label: string; +}; + +const countryItems: Array = [ + { id: 'au', label: 'Australia' }, + { id: 'ca', label: 'Canada' }, +]; + +const renderCountryItem = (item: CountryItem) => {item.label}; + +const originalDescriptor = Object.getOwnPropertyDescriptor(window, 'visualViewport'); + +afterEach(() => { + cleanupVisual(); + if (originalDescriptor) { + Object.defineProperty(window, 'visualViewport', originalDescriptor); + } else { + // @ts-expect-error -- deleting a test-only own property + delete window.visualViewport; + } +}); + +test('sets the tray viewport height and keyboard inset custom properties from visualViewport', async () => { + const fake = Object.assign(new EventTarget(), { + height: window.innerHeight - 300, + offsetTop: 0, + }); + Object.defineProperty(window, 'visualViewport', { configurable: true, value: fake }); + + renderVisual( + + {renderCountryItem} + , + ); + + await userEvent.click(page.getByRole('combobox', { name: 'Country' })); + await expect.element(page.getByRole('listbox')).toBeInTheDocument(); + + const popover = document.querySelector('[role="listbox"]')?.parentElement; + if (!popover) throw new Error('expected the listbox to have a popover parent'); + + expect(popover.style.getPropertyValue(comboboxTrayViewportHeightVar)).toBe(`${fake.height}px`); + expect(popover.style.getPropertyValue(comboboxTrayKeyboardInsetVar)).toBe( + `${window.innerHeight - fake.height}px`, + ); + + fake.height -= 100; + fake.dispatchEvent(new Event('resize')); + + expect(popover.style.getPropertyValue(comboboxTrayViewportHeightVar)).toBe(`${fake.height}px`); + expect(popover.style.getPropertyValue(comboboxTrayKeyboardInsetVar)).toBe( + `${window.innerHeight - fake.height}px`, + ); +}); diff --git a/packages/@luke-ui/react/src/combobox-field/primitive/use-visual-viewport-vars.ts b/packages/@luke-ui/react/src/combobox-field/primitive/use-visual-viewport-vars.ts index 0d387775..0d7b80c3 100644 --- a/packages/@luke-ui/react/src/combobox-field/primitive/use-visual-viewport-vars.ts +++ b/packages/@luke-ui/react/src/combobox-field/primitive/use-visual-viewport-vars.ts @@ -15,11 +15,25 @@ export function useVisualViewportVars(element: HTMLElement | null): void { const trayElement = element; const visualViewport = window.visualViewport; + // `innerHeight`/`clientHeight` don't reliably report the fixed-position containing + // block's height across browsers: on iOS Safari `innerHeight` tracks the visual + // viewport, so it shrinks with the keyboard and the old inset math collapsed to ~0. + // A probe pinned to all four edges via `inset: 0` measures that containing block + // directly, by construction, regardless of toolbar/keyboard state. + const probe = document.createElement('div'); + probe.style.position = 'fixed'; + probe.style.inset = '0'; + probe.style.visibility = 'hidden'; + probe.style.pointerEvents = 'none'; + document.body.append(probe); + function update() { trayElement.style.setProperty(comboboxTrayViewportHeightVar, `${visualViewport.height}px`); + // Read fresh each call in case the containing block itself changes (e.g. toolbar show/hide). + const probeHeight = probe.getBoundingClientRect().height; const keyboardInset = Math.max( 0, - window.innerHeight - visualViewport.height - visualViewport.offsetTop, + probeHeight - visualViewport.offsetTop - visualViewport.height, ); trayElement.style.setProperty(comboboxTrayKeyboardInsetVar, `${keyboardInset}px`); } @@ -33,6 +47,7 @@ export function useVisualViewportVars(element: HTMLElement | null): void { return () => { visualViewport.removeEventListener('resize', update); visualViewport.removeEventListener('scroll', update); + probe.remove(); }; }, [element]); }