diff --git a/packages/react/src/number-field/root/useNumberFieldRoot.ts b/packages/react/src/number-field/root/useNumberFieldRoot.ts index fe137241eb..4fbbb5d408 100644 --- a/packages/react/src/number-field/root/useNumberFieldRoot.ts +++ b/packages/react/src/number-field/root/useNumberFieldRoot.ts @@ -929,6 +929,8 @@ export namespace useNumberFieldRoot { inputValue: string; value: number | null; isScrubbing: boolean; + isTouchInput: boolean; + isPointerLockDenied: boolean; inputRef: ((instance: HTMLInputElement | null) => void) | null; scrubHandleRef: React.RefObject; scrubAreaRef: React.RefObject; diff --git a/packages/react/src/number-field/root/useScrub.ts b/packages/react/src/number-field/root/useScrub.ts index 4549bda54b..e8d8e2efcc 100644 --- a/packages/react/src/number-field/root/useScrub.ts +++ b/packages/react/src/number-field/root/useScrub.ts @@ -31,6 +31,8 @@ export function useScrub(params: ScrubParams) { const [isScrubbing, setIsScrubbing] = React.useState(false); const [cursorTransform, setCursorTransform] = React.useState(''); + const [isTouchInput, setIsTouchInput] = React.useState(false); + const [isPointerLockDenied, setIsPointerLockDenied] = React.useState(false); React.useEffect(() => { return () => { @@ -126,6 +128,9 @@ export function useScrub(params: ScrubParams) { return; } + const isTouch = event.pointerType === 'touch'; + setIsTouchInput(isTouch); + if (event.pointerType === 'mouse') { event.preventDefault(); inputRef.current?.focus(); @@ -135,7 +140,7 @@ export function useScrub(params: ScrubParams) { onScrubbingChange(true, event.nativeEvent); // WebKit causes significant layout shift with the native message, so we can't use it. - if (!isWebKit()) { + if (!isTouch && !isWebKit()) { // There can be some frames where there's no cursor at all when requesting the pointer lock. // This is a workaround to avoid flickering. avoidFlickerTimeoutRef.current = window.setTimeout(async () => { @@ -146,8 +151,9 @@ export function useScrub(params: ScrubParams) { // We need to await it even though it doesn't appear to return a promise in the // types in order for the `catch` to work. await ownerDocument(scrubAreaRef.current).body.requestPointerLock(); - } catch { - // + setIsPointerLockDenied(false); + } catch (error) { + setIsPointerLockDenied(true); } }, 20); } @@ -275,12 +281,14 @@ export function useScrub(params: ScrubParams) { return React.useMemo( () => ({ isScrubbing, + isTouchInput, + isPointerLockDenied, getScrubAreaProps, getScrubAreaCursorProps, scrubAreaCursorRef, scrubAreaRef, scrubHandleRef, }), - [isScrubbing, getScrubAreaProps, getScrubAreaCursorProps], + [isScrubbing, isTouchInput, isPointerLockDenied, getScrubAreaProps, getScrubAreaCursorProps], ); } diff --git a/packages/react/src/number-field/scrub-area-cursor/NumberFieldScrubAreaCursor.test.tsx b/packages/react/src/number-field/scrub-area-cursor/NumberFieldScrubAreaCursor.test.tsx index 483b6032ca..399f1ffaf3 100644 --- a/packages/react/src/number-field/scrub-area-cursor/NumberFieldScrubAreaCursor.test.tsx +++ b/packages/react/src/number-field/scrub-area-cursor/NumberFieldScrubAreaCursor.test.tsx @@ -1,14 +1,17 @@ import * as React from 'react'; import { expect } from 'chai'; -import { screen } from '@mui/internal-test-utils'; +import { screen, act } from '@mui/internal-test-utils'; +import sinon from 'sinon'; import { NumberField } from '@base-ui-components/react/number-field'; import { createRenderer, describeConformance } from '#test-utils'; import { isWebKit } from '../../utils/detectBrowser'; import { NumberFieldRootContext } from '../root/NumberFieldRootContext'; -const testContext = { +const defaultTestContext: NumberFieldRootContext = { getScrubAreaCursorProps: (externalProps) => externalProps, isScrubbing: true, + isTouchInput: false, + isPointerLockDenied: false, state: { value: null, required: false, @@ -30,7 +33,7 @@ describe('', () => { refInstanceof: window.HTMLSpanElement, render: async (node) => { return render( - + {node} , ); @@ -45,4 +48,88 @@ describe('', () => { ); expect(screen.queryByRole('presentation')).not.to.equal(null); }); + + it('renders when using mouse input', async () => { + const originalRequestPointerLock = Element.prototype.requestPointerLock; + + try { + Element.prototype.requestPointerLock = sinon.stub().resolves(); + + const { user } = await render( + + + + + , + ); + + const scrubArea = screen.getByTestId('scrub-area'); + + await act(async () => { + await user.pointer({ target: scrubArea, keys: '[MouseLeft>]', pointerName: 'mouse' }); + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + }); + + expect(screen.queryByTestId('scrub-area-cursor')).not.to.equal(null); + } finally { + Element.prototype.requestPointerLock = originalRequestPointerLock; + } + }); + + it('does not render when using touch input', async () => { + const { user } = await render( + + + + + , + ); + + const scrubArea = screen.getByRole('presentation'); + + await act(async () => { + await user.pointer({ target: scrubArea, keys: '[TouchA>]', pointerName: 'touch' }); + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + }); + + expect(screen.queryByTestId('scrub-area-cursor')).to.equal(null); + }); + + it('handles pointer lock denial through requestPointerLock API', async () => { + const originalRequestPointerLock = Element.prototype.requestPointerLock; + + try { + Element.prototype.requestPointerLock = sinon + .stub() + .throws(new Error('User denied pointer lock')); + + const { user } = await render( + + + + + , + ); + + const scrubArea = screen.getByRole('presentation'); + + await act(async () => { + await user.pointer({ target: scrubArea, keys: '[MouseLeft>]', pointerName: 'mouse' }); + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + }); + + expect(screen.queryByTestId('scrub-area-cursor')).to.equal(null); + + const requestLockStub = Element.prototype.requestPointerLock as sinon.SinonStub; + expect(requestLockStub.called).to.equal(true); + } finally { + Element.prototype.requestPointerLock = originalRequestPointerLock; + } + }); }); diff --git a/packages/react/src/number-field/scrub-area-cursor/NumberFieldScrubAreaCursor.tsx b/packages/react/src/number-field/scrub-area-cursor/NumberFieldScrubAreaCursor.tsx index 95918dc81e..86a345a829 100644 --- a/packages/react/src/number-field/scrub-area-cursor/NumberFieldScrubAreaCursor.tsx +++ b/packages/react/src/number-field/scrub-area-cursor/NumberFieldScrubAreaCursor.tsx @@ -25,8 +25,14 @@ const NumberFieldScrubAreaCursor = React.forwardRef(function NumberFieldScrubAre ) { const { render, className, ...otherProps } = props; - const { isScrubbing, scrubAreaCursorRef, state, getScrubAreaCursorProps } = - useNumberFieldRootContext(); + const { + isScrubbing, + isTouchInput, + isPointerLockDenied, + scrubAreaCursorRef, + state, + getScrubAreaCursorProps, + } = useNumberFieldRootContext(); const [element, setElement] = React.useState(null); @@ -41,7 +47,7 @@ const NumberFieldScrubAreaCursor = React.forwardRef(function NumberFieldScrubAre extraProps: otherProps, }); - if (!isScrubbing || isWebKit()) { + if (!isScrubbing || isWebKit() || isTouchInput || isPointerLockDenied) { return null; }