UNPKG

@base-ui/react

Version:

Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.

473 lines (459 loc) 20.2 kB
'use client'; import * as React from 'react'; import { getComputedStyle, getParentNode, isHTMLElement } from '@floating-ui/utils/dom'; import { addEventListener } from '@base-ui/utils/addEventListener'; import { ownerDocument, ownerWindow } from '@base-ui/utils/owner'; import { useAnimationFrame } from '@base-ui/utils/useAnimationFrame'; import { useStableCallback } from '@base-ui/utils/useStableCallback'; import { useDialogRootContext } from "../../dialog/root/DialogRootContext.mjs"; import { clamp } from "../../internals/clamp.mjs"; import { activeElement, contains, getTarget, isInteractiveElement } from "../../floating-ui-react/utils.mjs"; import { findScrollableTouchTarget } from "../../utils/scrollable.mjs"; import { getElementAtPoint } from "../../utils/getElementAtPoint.mjs"; import { DrawerViewportCssVars } from "../viewport/DrawerViewportCssVars.mjs"; import { DrawerVirtualKeyboardContext } from "./DrawerVirtualKeyboardContext.mjs"; import { jsx as _jsx } from "react/jsx-runtime"; const KEYBOARD_RESIZE_THRESHOLD = 60; const KEYBOARD_VISIBILITY_MARGIN = 16; // Extra breathing room (px) added below the focused field, on top of its measured // keyboard overlap, so the field can be scrolled clear of the keyboard instead of // ending up flush against it. Only applied when there is actual overlap. const KEYBOARD_SCROLL_SLACK = 48; const INPUT_TAP_MOVE_THRESHOLD = 10; const INPUT_TAP_HIT_SLOP = 16; const KEYBOARD_INPUT_TYPES = new Set(['email', 'number', 'password', 'search', 'tel', 'text', 'url']); // Snapshot of a scroll container's relevant styles taken before keyboard slack is // applied. The string fields are the exact inline values to restore on cleanup; // the parsed numbers are the computed baselines that slack is added on top of. // Returned by the point-based resolver when the lift point lands on another // interactive/label element. It signals that the tap was intentionally rejected, so the // caller must NOT fall back to the touchstart target (`touchend.target` stays at the // touchstart node on mobile) — doing so would steal a tap meant for that element. const KEYBOARD_TAP_BLOCKED = Symbol('KeyboardTapBlocked'); /** * Provides keyboard-aware focus and scroll handling for bottom-sheet drawers with form fields. * * Documentation: [Base UI Drawer](https://base-ui.com/react/components/drawer) */ export function DrawerVirtualKeyboardProvider(props) { const { children } = props; const { store } = useDialogRootContext(); const open = store.useState('open'); const mounted = store.useState('mounted'); const nestedOpenDialogCount = store.useState('nestedOpenDialogCount'); const viewportElement = store.useState('viewportElement'); // The provider requires a `<Drawer.Viewport>` to act as the measurement and containment // root and to host the keyboard inset variable; `<Drawer.Popup>` already warns when the // viewport is missing, so there is no need to fall back to the popup element here. const rootElement = viewportElement; const nestedDrawerOpen = nestedOpenDialogCount > 0; const pendingKeyboardFocusMovedRef = React.useRef(false); const keyboardTouchStartRef = React.useRef(null); const focusedKeyboardTargetRef = React.useRef(null); const keyboardScrollAdjustmentRef = React.useRef(null); const keyboardFocusFrame = useAnimationFrame(); const restoreKeyboardScrollAdjustment = useStableCallback(() => { const adjustment = keyboardScrollAdjustmentRef.current; if (!adjustment) { return; } adjustment.element.style.overflowAnchor = adjustment.overflowAnchor; adjustment.element.style.paddingBottom = adjustment.paddingBottom; adjustment.element.style.scrollPaddingBottom = adjustment.scrollPaddingBottom; keyboardScrollAdjustmentRef.current = null; }); const setKeyboardScrollSlack = useStableCallback((element, slack) => { const roundedSlack = Math.max(0, Math.ceil(slack)); let adjustment = keyboardScrollAdjustmentRef.current; if (adjustment && !adjustment.element.isConnected) { restoreKeyboardScrollAdjustment(); adjustment = null; } if (roundedSlack === 0) { restoreKeyboardScrollAdjustment(); return; } if (adjustment && adjustment.element !== element) { restoreKeyboardScrollAdjustment(); adjustment = null; } if (!adjustment) { const styles = getComputedStyle(element); adjustment = { element, overflowAnchor: element.style.overflowAnchor, paddingBottom: element.style.paddingBottom, scrollPaddingBottom: element.style.scrollPaddingBottom, computedPaddingBottom: Number.parseFloat(styles.paddingBottom) || 0, computedScrollPaddingBottom: Number.parseFloat(styles.scrollPaddingBottom) || 0 }; keyboardScrollAdjustmentRef.current = adjustment; } element.style.overflowAnchor = 'none'; element.style.paddingBottom = `${adjustment.computedPaddingBottom + roundedSlack}px`; element.style.scrollPaddingBottom = `${adjustment.computedScrollPaddingBottom + KEYBOARD_VISIBILITY_MARGIN}px`; }); const animateKeyboardScroll = useStableCallback((element, scrollTop) => { const win = ownerWindow(element); const behavior = win.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ? 'auto' : 'smooth'; element.scrollTo({ top: scrollTop, behavior }); }); const resetTouchTrackingState = useStableCallback(() => { pendingKeyboardFocusMovedRef.current = false; keyboardTouchStartRef.current = null; }); React.useEffect(() => { if (!mounted || !open) { focusedKeyboardTargetRef.current = null; restoreKeyboardScrollAdjustment(); keyboardFocusFrame.cancel(); return undefined; } if (!rootElement) { restoreKeyboardScrollAdjustment(); return undefined; } const doc = ownerDocument(rootElement); const win = ownerWindow(rootElement); const visualViewport = win.visualViewport; const setDrawerKeyboardInset = inset => { rootElement.style.setProperty(DrawerViewportCssVars.keyboardInset, `${Math.max(0, Math.ceil(inset))}px`); }; const resetDrawerKeyboardInset = () => { setDrawerKeyboardInset(0); }; const clearFocusedKeyboardTarget = () => { focusedKeyboardTargetRef.current = null; resetDrawerKeyboardInset(); restoreKeyboardScrollAdjustment(); keyboardFocusFrame.cancel(); }; const alignFocusedKeyboardTarget = () => { const target = focusedKeyboardTargetRef.current; // If the focused field is removed from the DOM without firing `focusout` (e.g. it is // conditionally rendered away), any applied scroll slack is restored here on the next // focus/viewport event or when the drawer closes. This self-corrects rather than // tracking each field's lifecycle. if (nestedDrawerOpen || !target || !contains(rootElement, target)) { resetDrawerKeyboardInset(); restoreKeyboardScrollAdjustment(); return; } const keyboardViewport = getKeyboardVisualViewport(win); if (!keyboardViewport) { resetDrawerKeyboardInset(); restoreKeyboardScrollAdjustment(); return; } setDrawerKeyboardInset(getDrawerKeyboardInset(win, keyboardViewport)); const scrollTarget = findKeyboardScrollTarget(target, rootElement); if (!scrollTarget) { restoreKeyboardScrollAdjustment(); return; } if (!scrollTarget.isConnected || !contains(rootElement, scrollTarget)) { resetDrawerKeyboardInset(); restoreKeyboardScrollAdjustment(); return; } const scrollTargetRect = scrollTarget.getBoundingClientRect(); const clippedBottom = Math.min(scrollTargetRect.bottom, keyboardViewport.bottom); const overlap = Math.max(0, scrollTargetRect.bottom - keyboardViewport.bottom); setKeyboardScrollSlack(scrollTarget, overlap > 0 ? overlap + KEYBOARD_SCROLL_SLACK : 0); const maxScrollTop = Math.max(0, scrollTarget.scrollHeight - scrollTarget.clientHeight); if (maxScrollTop <= 0) { return; } const clippedTop = Math.max(scrollTargetRect.top, keyboardViewport.top); const visibleTop = clippedTop + KEYBOARD_VISIBILITY_MARGIN; const visibleBottom = clippedBottom - KEYBOARD_VISIBILITY_MARGIN; if (visibleBottom <= visibleTop) { return; } const targetRect = target.getBoundingClientRect(); const targetCenter = (targetRect.top + targetRect.bottom) / 2; const visibleCenter = (visibleTop + visibleBottom) / 2; const nextScrollTop = scrollTarget.scrollTop + targetCenter - visibleCenter; animateKeyboardScroll(scrollTarget, clamp(nextScrollTop, 0, maxScrollTop)); }; const scheduleKeyboardFocusAlignment = () => { keyboardFocusFrame.request(alignFocusedKeyboardTarget); }; const captureFocusedKeyboardTarget = eventTarget => { if (nestedDrawerOpen) { return false; } // Resolve through the same path as taps so contentEditable hosts (and labelled // controls) are normalized identically for the focus and touch paths. const target = resolveKeyboardInputTarget(eventTarget); if (!target || !contains(rootElement, target)) { return false; } focusedKeyboardTargetRef.current = target; return true; }; const handleFocusIn = event => { if (captureFocusedKeyboardTarget(getTarget(event))) { scheduleKeyboardFocusAlignment(); } }; const handleFocusOut = event => { if (captureFocusedKeyboardTarget(event.relatedTarget)) { scheduleKeyboardFocusAlignment(); return; } clearFocusedKeyboardTarget(); }; const handleViewportUpdate = () => { if (focusedKeyboardTargetRef.current || captureFocusedKeyboardTarget(activeElement(doc))) { scheduleKeyboardFocusAlignment(); } }; const cleanupListeners = []; if (visualViewport) { cleanupListeners.push(addEventListener(visualViewport, 'resize', handleViewportUpdate), addEventListener(visualViewport, 'scroll', handleViewportUpdate)); } cleanupListeners.push(addEventListener(doc, 'focusin', handleFocusIn, true), addEventListener(doc, 'focusout', handleFocusOut, true)); if (captureFocusedKeyboardTarget(activeElement(doc))) { scheduleKeyboardFocusAlignment(); } return () => { cleanupListeners.forEach(cleanup => cleanup()); clearFocusedKeyboardTarget(); rootElement.style.removeProperty(DrawerViewportCssVars.keyboardInset); }; }, [animateKeyboardScroll, keyboardFocusFrame, mounted, nestedDrawerOpen, open, restoreKeyboardScrollAdjustment, rootElement, setKeyboardScrollSlack]); const onTouchStart = useStableCallback(event => { if (!open || !mounted || nestedDrawerOpen) { resetTouchTrackingState(); return; } const touch = event.touches[0]; if (!touch) { return; } pendingKeyboardFocusMovedRef.current = false; keyboardTouchStartRef.current = { x: touch.clientX, y: touch.clientY }; }); const onTouchMove = useStableCallback(event => { const touch = event.touches[0]; const touchStart = keyboardTouchStartRef.current; if (!touch || !touchStart || pendingKeyboardFocusMovedRef.current) { return; } // Treat the gesture as a scroll/swipe (not a tap-to-focus) once the finger // moves past the threshold, so we don't open the keyboard on a drag. if (Math.abs(touch.clientX - touchStart.x) > INPUT_TAP_MOVE_THRESHOLD || Math.abs(touch.clientY - touchStart.y) > INPUT_TAP_MOVE_THRESHOLD) { pendingKeyboardFocusMovedRef.current = true; } }); const onTouchEnd = useStableCallback(event => { if (!open || !mounted || nestedDrawerOpen || !rootElement || !keyboardTouchStartRef.current || pendingKeyboardFocusMovedRef.current) { resetTouchTrackingState(); return; } const touch = event.changedTouches[0] ?? event.touches[0]; const doc = ownerDocument(event.currentTarget); const nativeEventTarget = getTarget(event.nativeEvent); const pointTarget = touch ? resolveKeyboardTouchTargetFromPoint(doc, touch.clientX, touch.clientY) : null; // The lift point landed on another interactive/label element; let its native tap // through instead of stealing it for the touchstart input. if (pointTarget === KEYBOARD_TAP_BLOCKED) { resetTouchTrackingState(); return; } const keyboardTarget = touch && (pointTarget ?? resolveKeyboardTouchTarget(nativeEventTarget)); if (keyboardTarget && (!contains(rootElement, keyboardTarget.focusTarget) || !contains(rootElement, keyboardTarget.clickTarget))) { resetTouchTrackingState(); return; } if (keyboardTarget) { const { clickTarget: keyboardClickTarget, focusTarget: keyboardFocusTarget } = keyboardTarget; const win = ownerWindow(keyboardFocusTarget); // While pinch-zoomed, keyboard alignment is suspended; let native behavior // handle focus and caret placement instead of blurring and re-focusing. if (win.visualViewport && win.visualViewport.scale !== 1) { resetTouchTrackingState(); return; } // Already focused with the keyboard up: let the native tap through so it can // reposition the caret, rather than blurring and re-focusing the same input. if (activeElement(ownerDocument(keyboardFocusTarget)) === keyboardFocusTarget && isKeyboardVisualViewportOpen(win)) { resetTouchTrackingState(); return; } // iOS only opens the software keyboard when focus happens synchronously // inside the touch gesture. event.preventDefault(); focusKeyboardInputWithoutPageScroll(keyboardFocusTarget); // Preventing the touchend default also suppresses the compatibility mouse // events, including `click`; redispatch an untrusted replacement on the // original tap target so click handlers still run with the tap coordinates. dispatchKeyboardClick(keyboardClickTarget, touch); resetTouchTrackingState(); return; } resetTouchTrackingState(); }); const contextValue = React.useMemo(() => ({ onTouchStart, onTouchMove, onTouchEnd, onTouchCancel: resetTouchTrackingState }), [onTouchEnd, onTouchMove, onTouchStart, resetTouchTrackingState]); return /*#__PURE__*/_jsx(DrawerVirtualKeyboardContext.Provider, { value: contextValue, children: children }); } function isKeyboardInputElement(element) { if (element.isContentEditable) { return true; } const win = ownerWindow(element); if (element instanceof win.HTMLTextAreaElement || element instanceof win.HTMLInputElement && KEYBOARD_INPUT_TYPES.has(element.type)) { // Disabled controls can't focus or open the keyboard, so tap-to-focus must skip them — // otherwise the dispatched click fires handlers a native tap on a disabled control never would. return !element.matches(':disabled'); } return false; } function resolveKeyboardInputTarget(target) { if (!isHTMLElement(target)) { return null; } if (isKeyboardInputElement(target)) { return target.isContentEditable ? getContentEditableHost(target) : target; } const label = target.closest('label'); const control = label?.control ?? null; return isHTMLElement(control) && isKeyboardInputElement(control) ? control : null; } function resolveKeyboardTouchTarget(target) { const focusTarget = resolveKeyboardInputTarget(target); if (!focusTarget) { return null; } return { focusTarget, clickTarget: isHTMLElement(target) ? target : focusTarget }; } // Inherited-editable descendants (no `contenteditable` attribute of their own) are not // focusable, so focusing them is a no-op; resolve taps on them to the editing host. function getContentEditableHost(element) { let host = element; while (host.parentElement?.isContentEditable) { host = host.parentElement; } return host; } function resolveKeyboardTouchTargetFromPoint(doc, clientX, clientY) { const exactTarget = getElementAtPoint(doc, clientX, clientY); const exactKeyboardTarget = resolveKeyboardInputTarget(exactTarget); if (exactKeyboardTarget) { return { focusTarget: exactKeyboardTarget, clickTarget: isHTMLElement(exactTarget) ? exactTarget : exactKeyboardTarget }; } // Probing nearby points compensates for iOS retargeting taps while the page reacts // to the keyboard, but it must not steal a tap that lands on another interactive // element — that would suppress its click and focus a neighboring field instead. // `closest('label')` covers labels of non-keyboard controls (e.g. checkboxes). // Returning the blocked sentinel (rather than `null`) stops the caller from falling // back to the touchstart target, which would re-steal the very tap rejected here. if (isInteractiveElement(exactTarget) || exactTarget?.closest('label') != null) { return KEYBOARD_TAP_BLOCKED; } for (const [offsetX, offsetY] of [[0, INPUT_TAP_HIT_SLOP], [0, -INPUT_TAP_HIT_SLOP], [INPUT_TAP_HIT_SLOP, 0], [-INPUT_TAP_HIT_SLOP, 0]]) { const keyboardTarget = resolveKeyboardInputTarget(getElementAtPoint(doc, clientX + offsetX, clientY + offsetY)); if (keyboardTarget) { return { focusTarget: keyboardTarget, clickTarget: keyboardTarget }; } } return null; } function dispatchKeyboardClick(target, touch) { const win = ownerWindow(target); const ClickEvent = win.PointerEvent ?? win.MouseEvent; target.dispatchEvent(new ClickEvent('click', { bubbles: true, cancelable: true, clientX: touch.clientX, clientY: touch.clientY, detail: 1, view: win })); } function focusKeyboardInputWithoutPageScroll(target) { const wasFocused = activeElement(ownerDocument(target)) === target; const previousOpacity = target.style.opacity; const previousTransform = target.style.transform; const previousTransition = target.style.transition; // iOS Safari can still scroll the page for transformed sheets even with preventScroll. // Move the input off-screen only for the synchronous focus call. target.style.transition = 'none'; target.style.opacity = '0'; target.style.transform = 'translateY(-2000px)'; try { if (wasFocused) { target.blur(); } target.focus({ preventScroll: true }); } finally { target.style.opacity = previousOpacity; target.style.transform = previousTransform; target.style.transition = previousTransition; } } function findKeyboardScrollTarget(target, root) { // Start at the parent: scrolling the focused field's own content (an overflowing // textarea is scrollable itself) can never move its box out from under the keyboard. // `getParentNode` crosses shadow boundaries so an input inside a shadow root still reaches // the drawer body scroller. Prefer an already-scrollable ancestor, then fall back to one // that only becomes scrollable once keyboard slack is added (overflow intent without // current overflow). const scrollStart = getParentNode(target); return findScrollableTouchTarget(scrollStart, root, 'vertical') ?? findScrollableTouchTarget(scrollStart, root, 'vertical', true); } function getKeyboardVisualViewport(win) { const visualViewport = win.visualViewport; if (!visualViewport || visualViewport.scale !== 1) { return null; } const reducedHeight = win.innerHeight - visualViewport.height; // Treat small viewport changes as browser chrome movement, not the software keyboard. if (reducedHeight <= KEYBOARD_RESIZE_THRESHOLD) { return null; } const top = Math.max(0, visualViewport.offsetTop); return { top, bottom: Math.min(win.innerHeight, top + visualViewport.height) }; } function getDrawerKeyboardInset(win, keyboardViewport) { return Math.max(0, win.innerHeight - keyboardViewport.bottom); } function isKeyboardVisualViewportOpen(win) { return !win.visualViewport || getKeyboardVisualViewport(win) != null; }