diff --git a/docs/src/app/(docs)/react/components/drawer/demos/virtual-keyboard-aware/css-modules/index.module.css b/docs/src/app/(docs)/react/components/drawer/demos/virtual-keyboard-aware/css-modules/index.module.css new file mode 100644 index 00000000000..849a7911558 --- /dev/null +++ b/docs/src/app/(docs)/react/components/drawer/demos/virtual-keyboard-aware/css-modules/index.module.css @@ -0,0 +1,219 @@ +.Button { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + height: 2.5rem; + padding: 0 0.875rem; + margin: 0; + outline: 0; + border: 1px solid var(--color-gray-200); + border-radius: 0.375rem; + background-color: var(--color-gray-50); + font-family: inherit; + font-size: 1rem; + font-weight: 400; + line-height: 1.5rem; + color: var(--color-gray-900); + user-select: none; + + @media (hover: hover) { + &:hover { + background-color: var(--color-gray-100); + } + } + + &:active { + background-color: var(--color-gray-100); + } + + &:focus-visible { + outline: 2px solid var(--color-blue); + outline-offset: -1px; + } +} + +.Backdrop { + --backdrop-opacity: 0.2; + position: fixed; + min-height: 100dvh; + inset: 0; + background-color: black; + opacity: calc(var(--backdrop-opacity) * (1 - var(--drawer-swipe-progress))); + transition-duration: 450ms; + transition-property: opacity; + transition-timing-function: cubic-bezier(0.32, 0.72, 0, 1); + + @media (prefers-color-scheme: dark) { + --backdrop-opacity: 0.7; + } + + &[data-starting-style], + &[data-ending-style] { + opacity: 0; + } + + &[data-swiping] { + transition-duration: 0ms; + } + + &[data-ending-style] { + transition-duration: calc(var(--drawer-swipe-strength) * 400ms); + } +} + +.Viewport { + position: fixed; + inset: 0; + display: flex; + align-items: flex-end; + justify-content: center; + touch-action: none; +} + +.Popup { + --bleed: 3rem; + box-sizing: border-box; + position: relative; + display: flex; + flex-direction: column; + width: 100%; + max-height: calc(100dvh - var(--top-margin) + var(--bleed)); + margin-bottom: calc(-1 * var(--bleed)); + border-radius: 1rem 1rem 0 0; + outline: 1px solid var(--color-gray-200); + background-color: white; + color: var(--color-gray-900); + overflow: visible; + touch-action: none; + box-shadow: + 0 -16px 48px rgb(0 0 0 / 0.12), + 0 6px 18px rgb(0 0 0 / 0.06); + transform: translateY(var(--drawer-swipe-movement-y)); + transition: + transform 450ms cubic-bezier(0.32, 0.72, 0, 1), + box-shadow 450ms cubic-bezier(0.32, 0.72, 0, 1); + will-change: transform; + + &[data-swiping] { + user-select: none; + } + + &[data-starting-style], + &[data-ending-style] { + transform: translateY(calc(100% - var(--bleed) + 2px)); + box-shadow: + 0 -16px 48px rgb(0 0 0 / 0), + 0 6px 18px rgb(0 0 0 / 0); + } + + &[data-ending-style] { + transition-duration: calc(var(--drawer-swipe-strength) * 400ms); + } +} + +.Chrome { + flex-shrink: 0; + padding: 0.875rem 1.5rem 0.75rem; + border-bottom: 1px solid var(--color-gray-200); + touch-action: none; + user-select: none; +} + +.Handle { + width: 3rem; + height: 0.25rem; + margin: 0 auto 0.625rem; + flex-shrink: 0; + border-radius: 9999px; + background-color: var(--color-gray-300); +} + +.Title { + margin: 0; + font-size: 1.125rem; + line-height: 1.75rem; + font-weight: 700; + letter-spacing: -0.01em; + text-align: center; +} + +.Description { + margin: 0.25rem auto 0; + max-width: 28rem; + font-size: 0.9375rem; + line-height: 1.5rem; + color: var(--color-gray-600); + text-align: center; +} + +.Scroll { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + touch-action: auto; + padding: 1rem 1.5rem; +} + +.List { + display: grid; + gap: 0.75rem; + width: 100%; + max-width: 350px; + margin: 0 auto; +} + +.Card { + height: 3rem; + border: 1px solid var(--color-gray-200); + border-radius: 0.75rem; + background-color: var(--color-gray-100); +} + +.Footer { + flex-shrink: 0; + border-top: 1px solid var(--color-gray-200); + background-color: white; +} + +.FooterInner { + width: 100%; + max-width: 28rem; + margin: 0 auto; + padding: 1rem 1.5rem calc(1rem + env(safe-area-inset-bottom, 0px) + var(--bleed)); +} + +.Field { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.FieldLabel { + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 600; +} + +.Input { + width: 100%; + min-height: 2.75rem; + border: 1px solid var(--color-gray-200); + border-radius: 0.875rem; + background-color: white; + padding: 0.75rem 0.875rem; + font: inherit; + color: inherit; + + &:focus { + outline: 2px solid var(--color-blue); + outline-offset: -1px; + } +} + +.Actions { + display: flex; + justify-content: flex-end; + margin-top: 0.875rem; +} diff --git a/docs/src/app/(docs)/react/components/drawer/demos/virtual-keyboard-aware/css-modules/index.tsx b/docs/src/app/(docs)/react/components/drawer/demos/virtual-keyboard-aware/css-modules/index.tsx new file mode 100644 index 00000000000..13b8eb0c6a6 --- /dev/null +++ b/docs/src/app/(docs)/react/components/drawer/demos/virtual-keyboard-aware/css-modules/index.tsx @@ -0,0 +1,56 @@ +'use client'; +import * as React from 'react'; +import { Drawer } from '@base-ui/react/drawer'; +import styles from './index.module.css'; + +const TOP_MARGIN_REM = 2; + +export default function ExampleDrawerVirtualKeyboardAware() { + return ( + + Open keyboard-aware drawer + + + + +
+
+ Delivery checklist + + The list scrolls independently while the note field stays pinned to the bottom. + +
+ + +
+ {Array.from({ length: 16 }, (_, index) => ( +
+ ))} +
+ + +
+
+ + +
+ Close +
+
+
+ + + + + ); +} diff --git a/docs/src/app/(docs)/react/components/drawer/demos/virtual-keyboard-aware/index.ts b/docs/src/app/(docs)/react/components/drawer/demos/virtual-keyboard-aware/index.ts new file mode 100644 index 00000000000..891a1028483 --- /dev/null +++ b/docs/src/app/(docs)/react/components/drawer/demos/virtual-keyboard-aware/index.ts @@ -0,0 +1,8 @@ +import { createDemoWithVariants } from 'docs/src/utils/createDemo'; +import CssModules from './css-modules'; +import Tailwind from './tailwind'; + +export const DemoDrawerVirtualKeyboardAware = createDemoWithVariants(import.meta.url, { + CssModules, + Tailwind, +}); diff --git a/docs/src/app/(docs)/react/components/drawer/demos/virtual-keyboard-aware/tailwind/index.tsx b/docs/src/app/(docs)/react/components/drawer/demos/virtual-keyboard-aware/tailwind/index.tsx new file mode 100644 index 00000000000..7732da49a4a --- /dev/null +++ b/docs/src/app/(docs)/react/components/drawer/demos/virtual-keyboard-aware/tailwind/index.tsx @@ -0,0 +1,65 @@ +'use client'; +import * as React from 'react'; +import { Drawer } from '@base-ui/react/drawer'; + +const TOP_MARGIN_REM = 2; + +export default function ExampleDrawerVirtualKeyboardAware() { + return ( + + + Open keyboard-aware drawer + + + + + +
+
+ + Delivery checklist + + + The list scrolls independently while the note field stays pinned to the bottom. + +
+ + +
+ {Array.from({ length: 16 }, (_, index) => ( +
+ ))} +
+ + +
+
+ + +
+ + Close + +
+
+
+ + + + + ); +} diff --git a/docs/src/app/(docs)/react/components/drawer/page.mdx b/docs/src/app/(docs)/react/components/drawer/page.mdx index 5f9d1ca6746..c211d34740c 100644 --- a/docs/src/app/(docs)/react/components/drawer/page.mdx +++ b/docs/src/app/(docs)/react/components/drawer/page.mdx @@ -46,6 +46,8 @@ import { Drawer } from '@base-ui/react/drawer'; Drawer supports swipe gestures to dismiss. Set `swipeDirection` to control which direction dismisses the drawer. `` allows text selection of its children without swipe interference when using a mouse pointer. Add `data-base-ui-swipe-ignore` to a descendant when you need to opt that element out of swipe dismissal for all input types. +Bottom-sheet drawers automatically reposition focused text inputs for software keyboards. Set `disableInputRepositioning` to keep the legacy browser-driven behavior. + ## Examples ### State @@ -98,6 +100,7 @@ Positioning is handled by your styles. `swipeDirection` defaults to `"down"` for ``` import { DemoDrawerPosition } from './demos/position'; +import { DemoDrawerVirtualKeyboardAware } from './demos/virtual-keyboard-aware'; @@ -138,6 +141,16 @@ import { DemoDrawerSnapPoints } from './demos/snap-points'; By default, the drawer can skip snap points when swiping quickly. Specify the `snapToSequentialPoints` prop to disable velocity-based skipping so the snap target is determined by drag distance (you can still drag past multiple points). +### Virtual keyboard aware + +Bottom-sheet drawers automatically react to software keyboards. This pattern keeps the main list scrollable while a footer field stays pinned outside the scroll container. Set `disableInputRepositioning` on `` if you want to opt back into the legacy browser-driven scrolling behavior instead. + +```tsx title="Virtual keyboard aware drawer" +{/* ... */} +``` + + + ### Indent effect Scale the background down when any drawer opens by wrapping your app in `` and use `` + `` at the top of your tree. Any `` within the provider notifies it when it mounts, which activates the indent parts (they receive `[data-active]` state attributes). diff --git a/docs/src/app/(docs)/react/components/drawer/types.md b/docs/src/app/(docs)/react/components/drawer/types.md index 1707a82a9d0..d8ee8953b95 100644 --- a/docs/src/app/(docs)/react/components/drawer/types.md +++ b/docs/src/app/(docs)/react/components/drawer/types.md @@ -11,25 +11,26 @@ Doesn't render its own HTML element. **Root Props:** -| Prop | Type | Default | Description | -| :---------------------- | :------------------------------------------------------------------------------------------------------ | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| defaultOpen | `boolean` | `false` | Whether the drawer is initially open. To render a controlled drawer, use the `open` prop instead. | -| open | `boolean` | - | Whether the drawer is currently open. | -| onOpenChange | `((open: boolean, eventDetails: Drawer.Root.ChangeEventDetails) => void)` | - | Event handler called when the drawer is opened or closed. | -| snapPoints | `DrawerSnapPoint[]` | - | Snap points used to position the drawer. Use numbers between 0 and 1 to represent fractions of the viewport height, numbers greater than 1 as pixel values, or strings in `px`/`rem` units (for example, `'148px'` or `'30rem'`). | -| defaultSnapPoint | `DrawerSnapPoint \| null` | - | The initial snap point value when uncontrolled. | -| snapPoint | `DrawerSnapPoint \| null` | - | The currently active snap point. Use with `onSnapPointChange` to control the snap point. | -| onSnapPointChange | `((snapPoint: DrawerSnapPoint \| null, eventDetails: Drawer.Root.SnapPointChangeEventDetails) => void)` | - | Callback fired when the snap point changes. | -| actionsRef | `React.RefObject` | - | A ref to imperative actions. `unmount`: When specified, the drawer will not be unmounted when closed. Instead, the `unmount` function must be called to unmount the drawer manually. Useful when the drawer's animation is controlled by an external library.`close`: Closes the drawer imperatively when called. | -| defaultTriggerId | `string \| null` | - | ID of the trigger that the drawer is associated with. This is useful in conjunction with the `defaultOpen` prop to create an initially open drawer. | -| disablePointerDismissal | `boolean` | `false` | Determines whether the drawer should close on outside clicks. | -| handle | `Drawer.Handle` | - | A handle to associate the drawer with a trigger. If specified, allows detached triggers to control the drawer's open state. Can be created with the Drawer.createHandle() method. | -| modal | `boolean \| 'trap-focus'` | `true` | Determines if the drawer enters a modal state when open. `true`: user interaction is limited to just the drawer: focus is trapped, document page scroll is locked, and pointer interactions on outside elements are disabled.`false`: user interaction with the rest of the document is allowed.`'trap-focus'`: focus is trapped inside the drawer, but document page scroll is not locked and pointer interactions outside of it remain enabled. | -| onOpenChangeComplete | `((open: boolean) => void)` | - | Event handler called after any animations complete when the drawer is opened or closed. | -| snapToSequentialPoints | `boolean` | `false` | Disables velocity-based snap skipping so drag distance determines the next snap point. | -| swipeDirection | `DrawerSwipeDirection` | `'down'` | The swipe direction used to dismiss the drawer. | -| triggerId | `string \| null` | - | ID of the trigger that the drawer is associated with. This is useful in conjunction with the `open` prop to create a controlled drawer. There's no need to specify this prop when the drawer is uncontrolled (that is, when the `open` prop is not set). | -| children | `React.ReactNode \| PayloadChildRenderFunction` | - | The content of the drawer. | +| Prop | Type | Default | Description | +| :------------------------ | :------------------------------------------------------------------------------------------------------ | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| defaultOpen | `boolean` | `false` | Whether the drawer is initially open. To render a controlled drawer, use the `open` prop instead. | +| open | `boolean` | - | Whether the drawer is currently open. | +| onOpenChange | `((open: boolean, eventDetails: Drawer.Root.ChangeEventDetails) => void)` | - | Event handler called when the drawer is opened or closed. | +| snapPoints | `DrawerSnapPoint[]` | - | Snap points used to position the drawer. Use numbers between 0 and 1 to represent fractions of the viewport height, numbers greater than 1 as pixel values, or strings in `px`/`rem` units (for example, `'148px'` or `'30rem'`). | +| defaultSnapPoint | `DrawerSnapPoint \| null` | - | The initial snap point value when uncontrolled. | +| snapPoint | `DrawerSnapPoint \| null` | - | The currently active snap point. Use with `onSnapPointChange` to control the snap point. | +| onSnapPointChange | `((snapPoint: DrawerSnapPoint \| null, eventDetails: Drawer.Root.SnapPointChangeEventDetails) => void)` | - | Callback fired when the snap point changes. | +| actionsRef | `React.RefObject` | - | A ref to imperative actions. `unmount`: When specified, the drawer will not be unmounted when closed. Instead, the `unmount` function must be called to unmount the drawer manually. Useful when the drawer's animation is controlled by an external library.`close`: Closes the drawer imperatively when called. | +| defaultTriggerId | `string \| null` | - | ID of the trigger that the drawer is associated with. This is useful in conjunction with the `defaultOpen` prop to create an initially open drawer. | +| disableInputRepositioning | `boolean` | `false` | Disables automatic software-keyboard input repositioning for bottom-sheet drawers. | +| disablePointerDismissal | `boolean` | `false` | Determines whether the drawer should close on outside clicks. | +| handle | `Drawer.Handle` | - | A handle to associate the drawer with a trigger. If specified, allows detached triggers to control the drawer's open state. Can be created with the Drawer.createHandle() method. | +| modal | `boolean \| 'trap-focus'` | `true` | Determines if the drawer enters a modal state when open. `true`: user interaction is limited to just the drawer: focus is trapped, document page scroll is locked, and pointer interactions on outside elements are disabled.`false`: user interaction with the rest of the document is allowed.`'trap-focus'`: focus is trapped inside the drawer, but document page scroll is not locked and pointer interactions outside of it remain enabled. | +| onOpenChangeComplete | `((open: boolean) => void)` | - | Event handler called after any animations complete when the drawer is opened or closed. | +| snapToSequentialPoints | `boolean` | `false` | Disables velocity-based snap skipping so drag distance determines the next snap point. | +| swipeDirection | `DrawerSwipeDirection` | `'down'` | The swipe direction used to dismiss the drawer. | +| triggerId | `string \| null` | - | ID of the trigger that the drawer is associated with. This is useful in conjunction with the `open` prop to create a controlled drawer. There's no need to specify this prop when the drawer is uncontrolled (that is, when the `open` prop is not set). | +| children | `React.ReactNode \| PayloadChildRenderFunction` | - | The content of the drawer. | ### Root.Props diff --git a/docs/src/app/(docs)/react/components/page.mdx b/docs/src/app/(docs)/react/components/page.mdx index 473dd5dd0a2..6d90a655dbb 100644 --- a/docs/src/app/(docs)/react/components/page.mdx +++ b/docs/src/app/(docs)/react/components/page.mdx @@ -706,6 +706,7 @@ A panel that slides in from the edge of the screen. - Position - Nested drawers - Snap points + - Virtual keyboard aware - Indent effect - Non-modal - Mobile navigation @@ -732,7 +733,7 @@ A panel that slides in from the edge of the screen. - Handle - Exports: - Drawer - Root - - Props: actionsRef, children, defaultOpen, defaultSnapPoint, defaultTriggerId, disablePointerDismissal, handle, modal, onOpenChange, onOpenChangeComplete, onSnapPointChange, open, snapPoint, snapPoints, snapToSequentialPoints, swipeDirection, triggerId + - Props: actionsRef, children, defaultOpen, defaultSnapPoint, defaultTriggerId, disableInputRepositioning, disablePointerDismissal, handle, modal, onOpenChange, onOpenChangeComplete, onSnapPointChange, open, snapPoint, snapPoints, snapToSequentialPoints, swipeDirection, triggerId - Drawer - Provider - Props: children - Drawer - Trigger diff --git a/packages/react/src/drawer/popup/DrawerPopup.tsx b/packages/react/src/drawer/popup/DrawerPopup.tsx index d306af5f04f..7a80452ab44 100644 --- a/packages/react/src/drawer/popup/DrawerPopup.tsx +++ b/packages/react/src/drawer/popup/DrawerPopup.tsx @@ -120,6 +120,7 @@ export const DrawerPopup = React.forwardRef(function DrawerPopup( const { render, className, style, finalFocus, initialFocus, ...elementProps } = componentProps; const { store } = useDialogRootContext(); + const popupRef = store.context.popupRef; const { swipeDirection, @@ -155,7 +156,6 @@ export const DrawerPopup = React.forwardRef(function DrawerPopup( const swipeStrength = swipe?.swipeStrength ?? null; const [popupHeight, setPopupHeight] = React.useState(0); - const popupHeightRef = React.useRef(0); if (process.env.NODE_ENV !== 'production') { @@ -175,7 +175,7 @@ export const DrawerPopup = React.forwardRef(function DrawerPopup( } const measureHeight = useStableCallback(() => { - const popupElement = store.context.popupRef.current; + const popupElement = popupRef.current; if (!popupElement) { return; } @@ -217,7 +217,7 @@ export const DrawerPopup = React.forwardRef(function DrawerPopup( return undefined; } - const popupElement = store.context.popupRef.current; + const popupElement = popupRef.current; if (!popupElement) { return undefined; } @@ -235,11 +235,9 @@ export const DrawerPopup = React.forwardRef(function DrawerPopup( return () => { resizeObserver.disconnect(); }; - }, [measureHeight, mounted, nestedDrawerOpen, onPopupHeightChange, store.context.popupRef]); + }, [measureHeight, mounted, nestedDrawerOpen, onPopupHeightChange, popupRef]); useIsoLayoutEffect(() => { - const popupRef = store.context.popupRef; - const syncNestedSwipeProgress = () => { const popupElement = popupRef.current; if (!popupElement) { @@ -256,15 +254,15 @@ export const DrawerPopup = React.forwardRef(function DrawerPopup( syncNestedSwipeProgress(); const unsubscribe = nestedSwipeProgressStore.subscribe(syncNestedSwipeProgress); + const popupElement = popupRef.current; return () => { unsubscribe(); - const popupElement = popupRef.current; if (popupElement) { popupElement.style.setProperty(DrawerBackdropCssVars.swipeProgress, '0'); } }; - }, [nestedSwipeProgressStore, store.context.popupRef]); + }, [nestedSwipeProgressStore, popupRef]); React.useEffect(() => { if (!open) { @@ -293,7 +291,7 @@ export const DrawerPopup = React.forwardRef(function DrawerPopup( useOpenChangeComplete({ open, - ref: store.context.popupRef, + ref: popupRef, onComplete() { if (open) { store.context.onOpenChangeComplete?.(true); @@ -301,7 +299,7 @@ export const DrawerPopup = React.forwardRef(function DrawerPopup( }, }); - const resolvedInitialFocus = initialFocus === undefined ? store.context.popupRef : initialFocus; + const resolvedInitialFocus = initialFocus === undefined ? popupRef : initialFocus; const setPopupElement = store.useStateSetter('popupElement'); @@ -388,7 +386,7 @@ export const DrawerPopup = React.forwardRef(function DrawerPopup( }, elementProps, ], - ref: [forwardedRef, store.context.popupRef, setPopupElement], + ref: [forwardedRef, popupRef, setPopupElement], stateAttributesMapping, }); diff --git a/packages/react/src/drawer/root/DrawerRoot.tsx b/packages/react/src/drawer/root/DrawerRoot.tsx index e6c8395cdf6..ee952335f9d 100644 --- a/packages/react/src/drawer/root/DrawerRoot.tsx +++ b/packages/react/src/drawer/root/DrawerRoot.tsx @@ -45,6 +45,7 @@ export function DrawerRoot(props: DrawerRoot.Props) triggerId: triggerIdProp, defaultTriggerId: defaultTriggerIdProp = null, swipeDirection = 'down', + disableInputRepositioning = false, snapToSequentialPoints = false, snapPoints, snapPoint: snapPointProp, @@ -174,6 +175,7 @@ export function DrawerRoot(props: DrawerRoot.Props) const contextValue: DrawerRootContext = React.useMemo( () => ({ + disableInputRepositioning, swipeDirection, snapToSequentialPoints, snapPoints, @@ -196,6 +198,7 @@ export function DrawerRoot(props: DrawerRoot.Props) }), [ resolvedActiveSnapPoint, + disableInputRepositioning, frontmostHeight, hasNestedDrawer, nestedSwiping, @@ -323,6 +326,11 @@ export interface DrawerRootProps { * @default 'down' */ swipeDirection?: DrawerSwipeDirection | undefined; + /** + * Disables automatic software-keyboard input repositioning for bottom-sheet drawers. + * @default false + */ + disableInputRepositioning?: boolean | undefined; /** * Snap points used to position the drawer. * Use numbers between 0 and 1 to represent fractions of the viewport height, diff --git a/packages/react/src/drawer/root/DrawerRootContext.ts b/packages/react/src/drawer/root/DrawerRootContext.ts index 2d26c684e99..d7d92e96384 100644 --- a/packages/react/src/drawer/root/DrawerRootContext.ts +++ b/packages/react/src/drawer/root/DrawerRootContext.ts @@ -12,6 +12,10 @@ export interface DrawerNestedSwipeProgressStore { } export interface DrawerRootContext { + /** + * Whether built-in mobile input repositioning should be disabled. + */ + disableInputRepositioning: boolean; swipeDirection: DrawerSwipeDirection; /** * Whether snap points can be skipped based on swipe velocity. diff --git a/packages/react/src/drawer/viewport/DrawerViewport.test.tsx b/packages/react/src/drawer/viewport/DrawerViewport.test.tsx index abc5ee35b4f..1ac69a1c173 100644 --- a/packages/react/src/drawer/viewport/DrawerViewport.test.tsx +++ b/packages/react/src/drawer/viewport/DrawerViewport.test.tsx @@ -3,7 +3,7 @@ import * as ReactDOM from 'react-dom'; import { Combobox } from '@base-ui/react/combobox'; import { Drawer } from '@base-ui/react/drawer'; import { Slider } from '@base-ui/react/slider'; -import { fireEvent, flushMicrotasks, screen, waitFor } from '@mui/internal-test-utils'; +import { act, fireEvent, flushMicrotasks, screen, waitFor } from '@mui/internal-test-utils'; import { createRenderer, isJSDOM } from '#test-utils'; describe('', () => { @@ -36,6 +36,97 @@ describe('', () => { return touchMove; } + function createNativeTouchEnd(target: EventTarget, point: { clientX: number; clientY: number }) { + const touchEnd = new Event('touchend', { bubbles: true, cancelable: true }); + Object.defineProperty(touchEnd, 'changedTouches', { + value: [createTouch(target, point)], + configurable: true, + }); + Object.defineProperty(touchEnd, 'touches', { + value: [], + configurable: true, + }); + return touchEnd; + } + + function mockVisualViewport(height: number) { + const originalDescriptor = Object.getOwnPropertyDescriptor(window, 'visualViewport'); + const listeners = new Map>(); + + const visualViewport: Pick & { + height: number; + offsetTop: number; + scale: number; + } = { + height, + offsetTop: 0, + scale: 1, + addEventListener(type: string, listener: EventListener) { + if (!listeners.has(type)) { + listeners.set(type, new Set()); + } + + listeners.get(type)?.add(listener); + }, + removeEventListener(type: string, listener: EventListener) { + listeners.get(type)?.delete(listener); + }, + }; + + Object.defineProperty(window, 'visualViewport', { + configurable: true, + value: visualViewport as VisualViewport, + }); + + return { + restore() { + if (originalDescriptor) { + Object.defineProperty(window, 'visualViewport', originalDescriptor); + } else { + Object.defineProperty(window, 'visualViewport', { + configurable: true, + value: undefined, + }); + } + }, + resize(nextHeight: number, nextOffsetTop = visualViewport.offsetTop) { + visualViewport.height = nextHeight; + visualViewport.offsetTop = nextOffsetTop; + listeners.get('resize')?.forEach((listener) => listener(new Event('resize'))); + }, + setScale(nextScale: number) { + visualViewport.scale = nextScale; + listeners.get('resize')?.forEach((listener) => listener(new Event('resize'))); + }, + }; + } + + function mockWindowInnerHeight(innerHeight: number) { + const originalDescriptor = Object.getOwnPropertyDescriptor(window, 'innerHeight'); + + Object.defineProperty(window, 'innerHeight', { + configurable: true, + value: innerHeight, + }); + + return () => { + if (originalDescriptor) { + Object.defineProperty(window, 'innerHeight', originalDescriptor); + } + }; + } + + function getInputRepositioningFiller(popup: HTMLElement): HTMLElement | null { + const element = popup.previousElementSibling; + + return element instanceof HTMLElement && + element.getAttribute('aria-hidden') === 'true' && + element.style.pointerEvents === 'none' && + element.style.bottom === '0px' + ? element + : null; + } + it('clears text selection on swipe start', async () => { await render( @@ -182,6 +273,628 @@ describe('', () => { } }); + it.skipIf(isJSDOM)('mutates popup styles while the visual viewport is reduced', async () => { + const restoreInnerHeight = mockWindowInnerHeight(800); + const visualViewport = mockVisualViewport(800); + let activeElementSpy: ReturnType | null = null; + + try { + await render( + + + + + + + + + , + ); + + const popup = screen.getByTestId('popup'); + const input = screen.getByTestId('input'); + activeElementSpy = vi.spyOn(document, 'activeElement', 'get'); + activeElementSpy.mockReturnValue(input); + + await act(async () => { + visualViewport.resize(500); + }); + + await waitFor(() => { + const filler = getInputRepositioningFiller(popup); + + expect(window.getComputedStyle(popup).maxHeight).toBe('500px'); + expect(popup.style.bottom).toBe(''); + expect(popup.style.transform).toBe('translateY(0px)'); + expect(popup.style.translate).toBe('0px -300px'); + expect(filler).not.toBeNull(); + expect(filler?.style.position).toBe('fixed'); + expect(filler?.style.bottom).toBe('0px'); + expect(filler?.style.height).toBe('301px'); + expect(filler?.style.backgroundColor).toBe('rgb(255, 255, 255)'); + expect(filler?.style.transition).toBe('none'); + expect(filler?.style.zIndex).toBe('1'); + }); + } finally { + activeElementSpy?.mockRestore(); + visualViewport.restore(); + restoreInnerHeight(); + } + }); + + it.skipIf(isJSDOM)( + 'preserves the visible top gap and negative bottom bleed when mutating popup styles', + async () => { + const restoreInnerHeight = mockWindowInnerHeight(800); + const visualViewport = mockVisualViewport(800); + let activeElementSpy: ReturnType | null = null; + let rectSpy: ReturnType | null = null; + + try { + await render( + + + + + + + + + , + ); + + const popup = screen.getByTestId('popup'); + const input = screen.getByTestId('input'); + activeElementSpy = vi.spyOn(document, 'activeElement', 'get'); + activeElementSpy.mockReturnValue(input); + rectSpy = vi.spyOn(popup, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 32, + width: 320, + height: 300, + top: 32, + right: 320, + bottom: 332, + left: 0, + toJSON() { + return {}; + }, + }); + + await act(async () => { + visualViewport.resize(500); + }); + + await waitFor(() => { + const filler = getInputRepositioningFiller(popup); + + expect(window.getComputedStyle(popup).maxHeight).toBe('516px'); + expect(popup.style.bottom).toBe(''); + expect(popup.style.translate).toBe('0px -300px'); + expect(filler).not.toBeNull(); + expect(filler?.style.bottom).toBe('0px'); + expect(filler?.style.width).toBe('320px'); + expect(filler?.style.height).toBe('301px'); + expect(filler?.style.transition).toBe('none'); + expect(filler?.style.zIndex).toBe('1'); + }); + } finally { + rectSpy?.mockRestore(); + activeElementSpy?.mockRestore(); + visualViewport.restore(); + restoreInnerHeight(); + } + }, + ); + + it.skipIf(isJSDOM)('accounts for the visual viewport offset when lifting the popup', async () => { + const restoreInnerHeight = mockWindowInnerHeight(800); + const visualViewport = mockVisualViewport(800); + let activeElementSpy: ReturnType | null = null; + let rectSpy: ReturnType | null = null; + const originalElementFromPoint = document.elementFromPoint; + + try { + await render( + + + + + + + + + , + ); + + const popup = screen.getByTestId('popup'); + const input = screen.getByTestId('input'); + activeElementSpy = vi.spyOn(document, 'activeElement', 'get'); + rectSpy = vi.spyOn(popup, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 52, + width: 320, + height: 300, + top: 52, + right: 320, + bottom: 352, + left: 0, + toJSON() { + return {}; + }, + }); + document.elementFromPoint = () => input; + + fireEvent.touchStart(input, { + touches: [ + createTouch(input, { + clientX: 0, + clientY: 0, + }), + ], + }); + fireEvent.touchEnd(input, { + changedTouches: [ + createTouch(input, { + clientX: 0, + clientY: 0, + }), + ], + }); + activeElementSpy.mockReturnValue(input); + + await act(async () => { + visualViewport.resize(500, 20); + }); + + await waitFor(() => { + const filler = getInputRepositioningFiller(popup); + + expect(window.getComputedStyle(popup).maxHeight).toBe('none'); + expect(popup.style.top).toBe('52px'); + expect(popup.style.bottom).toBe('280px'); + expect(popup.style.translate).toBe('0px'); + expect(filler).not.toBeNull(); + expect(filler?.style.height).toBe('281px'); + expect(filler?.style.transition).toContain('height 400ms'); + }); + } finally { + document.elementFromPoint = originalElementFromPoint; + rectSpy?.mockRestore(); + activeElementSpy?.mockRestore(); + visualViewport.restore(); + restoreInnerHeight(); + } + }); + + it.skipIf(isJSDOM)( + 'does not mutate popup styles when input repositioning is disabled', + async () => { + const restoreInnerHeight = mockWindowInnerHeight(800); + const visualViewport = mockVisualViewport(800); + let activeElementSpy: ReturnType | null = null; + + try { + await render( + + + + + + + + + , + ); + + const popup = screen.getByTestId('popup'); + const input = screen.getByTestId('input'); + activeElementSpy = vi.spyOn(document, 'activeElement', 'get'); + activeElementSpy.mockReturnValue(input); + + await act(async () => { + visualViewport.resize(500); + }); + + await waitFor(() => { + expect(popup.style.maxHeight).toBe(''); + expect(popup.style.bottom).toBe(''); + expect(popup.style.translate).toBe(''); + expect(getInputRepositioningFiller(popup)).toBeNull(); + }); + } finally { + activeElementSpy?.mockRestore(); + visualViewport.restore(); + restoreInnerHeight(); + } + }, + ); + + it.skipIf(isJSDOM)( + 'neutralizes nested drawer stack transforms while mutating popup styles', + async () => { + const restoreInnerHeight = mockWindowInnerHeight(800); + const visualViewport = mockVisualViewport(800); + let activeElementSpy: ReturnType | null = null; + const originalElementFromPoint = document.elementFromPoint; + + try { + await render( + + + + + + + + + + + + + + + + + , + ); + + const popup = screen.getByTestId('nested-popup'); + const input = screen.getByTestId('nested-input'); + activeElementSpy = vi.spyOn(document, 'activeElement', 'get'); + document.elementFromPoint = () => input; + + fireEvent.touchStart(input, { + touches: [ + createTouch(input, { + clientX: 0, + clientY: 0, + }), + ], + }); + fireEvent.touchEnd(input, { + changedTouches: [ + createTouch(input, { + clientX: 0, + clientY: 0, + }), + ], + }); + activeElementSpy.mockReturnValue(input); + + await act(async () => { + visualViewport.resize(500); + }); + + await waitFor(() => { + const filler = getInputRepositioningFiller(popup); + + expect(window.getComputedStyle(popup).maxHeight).toBe('490px'); + expect(popup.style.bottom).toBe(''); + expect(popup.style.transform).toBe('none'); + expect(popup.style.translate).toBe('0px -300px'); + expect(filler).not.toBeNull(); + expect(filler?.style.height).toBe('301px'); + expect(filler?.style.transition).toBe('none'); + }); + } finally { + document.elementFromPoint = originalElementFromPoint; + activeElementSpy?.mockRestore(); + visualViewport.restore(); + restoreInnerHeight(); + } + }, + ); + + it.skipIf(isJSDOM)( + 'starts closing input repositioning on blur before the visual viewport is restored', + async () => { + const restoreInnerHeight = mockWindowInnerHeight(800); + const visualViewport = mockVisualViewport(800); + let activeElementSpy: ReturnType | null = null; + + try { + await render( + + + + + + + + + , + ); + + const popup = screen.getByTestId('popup'); + const input = screen.getByTestId('input'); + activeElementSpy = vi.spyOn(document, 'activeElement', 'get'); + activeElementSpy.mockReturnValue(input); + + await act(async () => { + visualViewport.resize(500); + }); + + await waitFor(() => { + expect(popup.style.translate).toBe('0px -300px'); + }); + + fireEvent.focusOut(input, { relatedTarget: null }); + + expect(popup.style.maxHeight).not.toBe(''); + expect(popup.style.translate).toBe('0px'); + } finally { + activeElementSpy?.mockRestore(); + visualViewport.restore(); + restoreInnerHeight(); + } + }, + ); + + it.skipIf(isJSDOM)( + 'restores authored max-height immediately on blur for self-scrolling popups', + async () => { + const restoreInnerHeight = mockWindowInnerHeight(800); + const visualViewport = mockVisualViewport(800); + let activeElementSpy: ReturnType | null = null; + + try { + await render( + + + + + + + + + , + ); + + const popup = screen.getByTestId('popup'); + const input = screen.getByTestId('input'); + activeElementSpy = vi.spyOn(document, 'activeElement', 'get'); + activeElementSpy.mockReturnValue(input); + + await act(async () => { + visualViewport.resize(500); + }); + + await waitFor(() => { + expect(popup.style.translate).toBe('0px -300px'); + }); + + fireEvent.focusOut(input, { relatedTarget: null }); + + expect(popup.style.maxHeight).toBe(''); + expect(popup.style.translate).toBe('0px'); + } finally { + activeElementSpy?.mockRestore(); + visualViewport.restore(); + restoreInnerHeight(); + } + }, + ); + + it.skipIf(isJSDOM)( + 'scrolls the popup instead of a focused textarea when revealing it', + async () => { + const restoreInnerHeight = mockWindowInnerHeight(800); + const visualViewport = mockVisualViewport(800); + let activeElementSpy: ReturnType | null = null; + let popupRectSpy: ReturnType | null = null; + let textareaRectSpy: ReturnType | null = null; + + try { + await render( + + + + +