diff --git a/package.json b/package.json index 47a7339cf26..54623878ebf 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "@storybook/react": "^5.2.1", "@testing-library/dom": "^7.23.0", "@testing-library/jest-dom": "^5.11.4", - "@testing-library/react": "^10.4.9", + "@testing-library/react": "^11.0.4", "@testing-library/react-hooks": "^3.4.1", "@testing-library/user-event": "^12.1.3", "@types/react": "^16.9.23", diff --git a/packages/@adobe/spectrum-css-temp/components/slider/index.css b/packages/@adobe/spectrum-css-temp/components/slider/index.css index 83c0134c6e3..0e80856523a 100644 --- a/packages/@adobe/spectrum-css-temp/components/slider/index.css +++ b/packages/@adobe/spectrum-css-temp/components/slider/index.css @@ -55,6 +55,7 @@ governing permissions and limitations under the License. display: block; user-select: none; + touch-action: none; } .spectrum-Slider-controls { diff --git a/packages/@adobe/spectrum-css-temp/components/splitview/index.css b/packages/@adobe/spectrum-css-temp/components/splitview/index.css index dfdac1a9b9d..8afdac6db3b 100644 --- a/packages/@adobe/spectrum-css-temp/components/splitview/index.css +++ b/packages/@adobe/spectrum-css-temp/components/splitview/index.css @@ -43,6 +43,8 @@ governing permissions and limitations under the License. width: var(--spectrum-rail-gripper-width); height: var(--spectrum-rail-gripper-height); border-width: var(--spectrum-rail-gripper-border-width-vertical) var(--spectrum-rail-gripper-border-width-horizontal); + + touch-action: none; } .spectrum-SplitView-splitter { @@ -52,6 +54,8 @@ governing permissions and limitations under the License. /* Prevent text selection while dragging */ user-select: none; + touch-action: none; + width: var(--spectrum-rail-handle-width); height: 100%; z-index: 1; diff --git a/packages/@react-aria/interactions/src/index.ts b/packages/@react-aria/interactions/src/index.ts index 509453819f8..4fc5357977b 100644 --- a/packages/@react-aria/interactions/src/index.ts +++ b/packages/@react-aria/interactions/src/index.ts @@ -10,12 +10,13 @@ * governing permissions and limitations under the License. */ -export * from './usePress'; -export * from './useInteractOutside'; export * from './Pressable'; export * from './PressResponder'; -export * from './useKeyboard'; export * from './useFocus'; -export * from './useFocusWithin'; export * from './useFocusVisible'; +export * from './useFocusWithin'; export * from './useHover'; +export * from './useInteractOutside'; +export * from './useKeyboard'; +export * from './useMove'; +export * from './usePress'; diff --git a/packages/@react-aria/interactions/src/useMove.ts b/packages/@react-aria/interactions/src/useMove.ts new file mode 100644 index 00000000000..8a2513cafd5 --- /dev/null +++ b/packages/@react-aria/interactions/src/useMove.ts @@ -0,0 +1,223 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {disableTextSelection, restoreTextSelection} from './textSelection'; +import {HTMLAttributes, useMemo, useRef} from 'react'; +import {useGlobalListeners} from '@react-aria/utils'; + +export interface BaseMoveEvent { + pointerType: 'mouse' | 'pen' | 'touch' | 'keyboard' +} + +export interface MoveStartEvent extends BaseMoveEvent{ + type: 'movestart' +} + +export interface MoveMoveEvent extends BaseMoveEvent{ + type: 'move', + deltaX: number, + deltaY: number +} + +export interface MoveEndEvent extends BaseMoveEvent{ + type: 'moveend' +} + +export type MoveEvent = MoveStartEvent | MoveMoveEvent | MoveEndEvent; + +export interface MoveProps { + onMoveStart?: (e: MoveStartEvent) => void, + onMove: (e: MoveMoveEvent) => void, + onMoveEnd?: (e: MoveEndEvent) => void +} + +export function useMove(props: MoveProps): HTMLAttributes { + let {onMoveStart, onMove, onMoveEnd} = props; + + let state = useRef<{ + didMove: boolean, + lastPosition: {pageX: number, pageY: number} | null, + id: number | null + }>({didMove: false, lastPosition: null, id: null}); + + let {addGlobalListener, removeGlobalListener} = useGlobalListeners(); + + let moveProps = useMemo(() => { + let moveProps: HTMLAttributes = {}; + + let start = () => { + disableTextSelection(); + state.current.didMove = false; + }; + let move = (pointerType: BaseMoveEvent['pointerType'], deltaX: number, deltaY: number) => { + if (!state.current.didMove) { + state.current.didMove = true; + onMoveStart?.({ + type: 'movestart', + pointerType + }); + } + onMove({ + type: 'move', + pointerType, + deltaX: deltaX, + deltaY: deltaY + }); + }; + let end = (pointerType: BaseMoveEvent['pointerType']) => { + restoreTextSelection(); + if (state.current.didMove) { + onMoveEnd?.({ + type: 'moveend', + pointerType + }); + } + }; + + if (typeof PointerEvent === 'undefined') { + let onMouseMove = (e: MouseEvent) => { + if (e.button === 0) { + move('mouse', e.pageX - state.current.lastPosition.pageX, e.pageY - state.current.lastPosition.pageY); + state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY}; + } + }; + let onMouseUp = (e: MouseEvent) => { + if (e.button === 0) { + end('mouse'); + removeGlobalListener(window, 'mousemove', onMouseMove, false); + removeGlobalListener(window, 'mouseup', onMouseUp, false); + } + }; + moveProps.onMouseDown = (e: React.MouseEvent) => { + if (e.button === 0) { + start(); + e.stopPropagation(); + e.preventDefault(); + state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY}; + addGlobalListener(window, 'mousemove', onMouseMove, false); + addGlobalListener(window, 'mouseup', onMouseUp, false); + } + }; + + let onTouchMove = (e: TouchEvent) => { + // @ts-ignore + let touch = [...e.changedTouches].findIndex(({identifier}) => identifier === state.current.id); + if (touch >= 0) { + let {pageX, pageY} = e.changedTouches[touch]; + move('touch', pageX - state.current.lastPosition.pageX, pageY - state.current.lastPosition.pageY); + state.current.lastPosition = {pageX, pageY}; + } + }; + let onTouchEnd = (e: TouchEvent) => { + // @ts-ignore + let touch = [...e.changedTouches].findIndex(({identifier}) => identifier === state.current.id); + if (touch >= 0) { + end('touch'); + removeGlobalListener(window, 'touchmove', onTouchMove); + removeGlobalListener(window, 'touchend', onTouchEnd); + removeGlobalListener(window, 'touchcancel', onTouchEnd); + } + }; + moveProps.onTouchStart = (e: React.TouchEvent) => { + if (e.targetTouches.length === 0) { + return; + } + + let {pageX, pageY, identifier} = e.targetTouches[0]; + start(); + e.stopPropagation(); + e.preventDefault(); + state.current.lastPosition = {pageX, pageY}; + state.current.id = identifier; + addGlobalListener(window, 'touchmove', onTouchMove, false); + addGlobalListener(window, 'touchend', onTouchEnd, false); + addGlobalListener(window, 'touchcancel', onTouchEnd, false); + }; + } else { + let onPointerMove = (e: PointerEvent) => { + if (e.pointerId === state.current.id) { + // @ts-ignore + let pointerType: BaseMoveEvent['pointerType'] = e.pointerType || 'mouse'; + + // Problems with PointerEvent#movementX/movementY: + // 1. it is always 0 on macOS Safari. + // 2. On Chrome Android, it's scaled by devicePixelRatio, but not on Chrome macOS + move(pointerType, e.pageX - state.current.lastPosition.pageX, e.pageY - state.current.lastPosition.pageY); + state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY}; + } + }; + + let onPointerUp = (e: PointerEvent) => { + if (e.pointerId === state.current.id) { + // @ts-ignore + let pointerType: BaseMoveEvent['pointerType'] = e.pointerType || 'mouse'; + end(pointerType); + removeGlobalListener(window, 'pointermove', onPointerMove, false); + removeGlobalListener(window, 'pointerup', onPointerUp, false); + removeGlobalListener(window, 'pointercancel', onPointerUp, false); + } + }; + + moveProps.onPointerDown = (e: React.PointerEvent) => { + if (e.button === 0) { + start(); + e.stopPropagation(); + e.preventDefault(); + state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY}; + state.current.id = e.pointerId; + addGlobalListener(window, 'pointermove', onPointerMove, false); + addGlobalListener(window, 'pointerup', onPointerUp, false); + addGlobalListener(window, 'pointercancel', onPointerUp, false); + } + }; + } + + let triggetKeyboardMove = (deltaX: number, deltaY: number) => { + start(); + move('keyboard', deltaX, deltaY); + end('keyboard'); + }; + + moveProps.onKeyDown = (e) => { + switch (e.key) { + case 'Left': + case 'ArrowLeft': + e.preventDefault(); + e.stopPropagation(); + triggetKeyboardMove(-1, 0); + break; + case 'Right': + case 'ArrowRight': + e.preventDefault(); + e.stopPropagation(); + triggetKeyboardMove(1, 0); + break; + case 'Up': + case 'ArrowUp': + e.preventDefault(); + e.stopPropagation(); + triggetKeyboardMove(0, -1); + break; + case 'Down': + case 'ArrowDown': + e.preventDefault(); + e.stopPropagation(); + triggetKeyboardMove(0, 1); + break; + } + }; + + return moveProps; + }, [state, onMoveStart, onMove, onMoveEnd]); + + return moveProps; +} diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index 28a6160f866..951f984bfe0 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -17,10 +17,11 @@ import {disableTextSelection, restoreTextSelection} from './textSelection'; import {focusWithoutScrolling, mergeProps} from '@react-aria/utils'; -import {HTMLAttributes, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import {HTMLAttributes, RefObject, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {isVirtualClick} from './utils'; import {PointerType, PressEvents} from '@react-types/shared'; import {PressResponderContext} from './context'; +import {useGlobalListeners} from '@react-aria/utils'; export interface PressProps extends PressEvents { /** Whether the target is in a controlled press state (e.g. an overlay it triggers is open). */ @@ -112,15 +113,7 @@ export function usePress(props: PressHookProps): PressResult { isOverTarget: false }); - let globalListeners = useRef(new Map()); - let addGlobalListener = useCallback((eventTarget, type, listener, options) => { - globalListeners.current.set(listener, {type, eventTarget, options}); - eventTarget.addEventListener(type, listener, options); - }, []); - let removeGlobalListener = useCallback((eventTarget, type, listener, options) => { - eventTarget.removeEventListener(type, listener, options); - globalListeners.current.delete(listener); - }, []); + let {addGlobalListener, removeGlobalListener} = useGlobalListeners(); let pressProps = useMemo(() => { let state = ref.current; @@ -542,15 +535,6 @@ export function usePress(props: PressHookProps): PressResult { return pressProps; }, [isDisabled, onPressStart, onPressChange, onPressEnd, onPress, onPressUp, addGlobalListener, preventFocusOnPress, removeGlobalListener]); - // eslint-disable-next-line arrow-body-style - useEffect(() => { - return () => { - globalListeners.current.forEach((value, key) => { - removeGlobalListener(value.eventTarget, value.type, key, value.options); - }); - }; - }, [removeGlobalListener]); - return { isPressed: isPressedProp || isPressed, pressProps: mergeProps(domProps, pressProps) diff --git a/packages/@react-aria/interactions/stories/useMove.stories.tsx b/packages/@react-aria/interactions/stories/useMove.stories.tsx new file mode 100644 index 00000000000..809751ef07d --- /dev/null +++ b/packages/@react-aria/interactions/stories/useMove.stories.tsx @@ -0,0 +1,128 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-disable jsx-a11y/no-noninteractive-tabindex */ + +import {action} from '@storybook/addon-actions'; +import {clamp} from '@react-aria/utils'; +import {Flex} from '@react-spectrum/layout'; +import React, {useRef, useState} from 'react'; +import {storiesOf} from '@storybook/react'; +import {useMove} from '../'; + +function useClampedMove(props) { + let currentPosition = useRef<{x?: number, y?: number}>(); + + let {getCurrentState, onMoveTo, onMoveStart, onMoveEnd, reverseX = false, reverseY = false} = props; + + let moveProps = useMove({ + onMoveStart(e) { + currentPosition.current = null; + onMoveStart?.(e); + }, + onMove({deltaX, deltaY, pointerType}) { + if (currentPosition.current == null) { + currentPosition.current = getCurrentState(); + } + + currentPosition.current.x += reverseX ? -deltaX : deltaX; + currentPosition.current.y += reverseY ? -deltaY : deltaY; + onMoveTo({pointerType, x: currentPosition.current.x, y: currentPosition.current.y}); + }, + onMoveEnd + }); + + return moveProps; +} + +function Ball1D() { + let [state, setState] = useState({x: 0, color: 'black'}); + + let props = useClampedMove({ + linear: 'horizontal', + reverseY: true, + onMoveStart() { setState((state) => ({...state, color: 'red'})); }, + onMoveTo({x}) { + setState((state) => ({...state, x: clamp(x, 0, 200 - 30), y: 0})); + }, + getCurrentState() { return {x: state.x, y: 0}; }, + onMoveEnd() { setState((state) => ({...state, color: 'black'})); } + }); + + return (
+
+
); +} + +storiesOf('useMove', module) + .add( + 'Log', + () => { + let props = useMove({ + onMoveStart(e) { action('onMoveStart')(JSON.stringify(e)); }, + onMove(e) { action('onMove')(JSON.stringify(e)); }, + onMoveEnd(e) { action('onMoveEnd')(JSON.stringify(e)); } + }); + + return
; + } + ) + .add( + 'Ball 1D', + () => ( + + + ) + ) + .add( + 'Ball 2D', + () => { + let [state, setState] = useState({x: 0, y: 0, color: 'black'}); + + let props = useClampedMove({ + onMoveStart() { setState((state) => ({...state, color: 'red'})); }, + onMoveTo({x, y}) { + setState((state) => ({...state, x: clamp(x, 0, 200 - 30), y: clamp(y, 0, 200 - 30)})); + }, + getCurrentState() { return {x: state.x, y: state.y}; }, + onMoveEnd() { setState((state) => ({...state, color: 'black'})); } + }); + + return (
+
+
); + } + ) + .add( + 'Ball nested', + () => { + let [ballState, setBallState] = useState({x: 0, y: 0, color: 'black'}); + let [boxState, setBoxState] = useState({x: 100, y: 100, color: 'grey'}); + + let ballProps = useMove({ + onMoveStart() { setBallState((state) => ({...state, color: 'red'})); }, + onMove(e) { setBallState((state) => ({...state, x: state.x + e.deltaX, y: state.y + e.deltaY})); }, + onMoveEnd() { setBallState((state) => ({...state, color: 'black'})); } + }); + let boxProps = useMove({ + onMoveStart() { setBoxState((state) => ({...state, color: 'orange'})); }, + onMove(e) { setBoxState((state) => ({...state, x: state.x + e.deltaX, y: state.y + e.deltaY})); }, + onMoveEnd() { setBoxState((state) => ({...state, color: 'grey'})); } + }); + + return ( +
+
+
+ ); + } + ); diff --git a/packages/@react-aria/interactions/test/useHover.test.js b/packages/@react-aria/interactions/test/useHover.test.js index 582c609cefd..74ca2e4714d 100644 --- a/packages/@react-aria/interactions/test/useHover.test.js +++ b/packages/@react-aria/interactions/test/useHover.test.js @@ -11,6 +11,7 @@ */ import {fireEvent, render} from '@testing-library/react'; +import {installPointerEvent} from '@react-spectrum/test-utils'; import React from 'react'; import {useHover} from '../'; @@ -54,13 +55,7 @@ describe('useHover', function () { }); describe('pointer events', function () { - beforeEach(() => { - global.PointerEvent = {}; - }); - - afterEach(() => { - delete global.PointerEvent; - }); + installPointerEvent(); it('should fire hover events based on pointer events', function () { let events = []; diff --git a/packages/@react-aria/interactions/test/useInteractOutside.test.js b/packages/@react-aria/interactions/test/useInteractOutside.test.js index 19220a15f16..8d4fb9625f0 100644 --- a/packages/@react-aria/interactions/test/useInteractOutside.test.js +++ b/packages/@react-aria/interactions/test/useInteractOutside.test.js @@ -11,6 +11,7 @@ */ import {fireEvent, render} from '@testing-library/react'; +import {installPointerEvent} from '@react-spectrum/test-utils'; import React, {useRef} from 'react'; import {useInteractOutside} from '../'; @@ -30,13 +31,7 @@ describe('useInteractOutside', function () { // TODO: JSDOM doesn't yet support pointer events. Once they do, convert these tests. // https://github.com/jsdom/jsdom/issues/2527 describe('pointer events', function () { - beforeEach(() => { - global.PointerEvent = {}; - }); - - afterEach(() => { - delete global.PointerEvent; - }); + installPointerEvent(); it('should fire interact outside events based on pointer events', function () { let onInteractOutside = jest.fn(); diff --git a/packages/@react-aria/interactions/test/useMove.test.js b/packages/@react-aria/interactions/test/useMove.test.js new file mode 100644 index 00000000000..a083223ab90 --- /dev/null +++ b/packages/@react-aria/interactions/test/useMove.test.js @@ -0,0 +1,345 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {fireEvent, render} from '@testing-library/react'; +import {installMouseEvent, installPointerEvent} from '@react-spectrum/test-utils'; +import React from 'react'; +import {useMove} from '../'; + +const EXAMPLE_ELEMENT_TESTID = 'example'; + +function Example(props) { + let moveProps = useMove(props); + return
{props.children}
; +} + +describe('useMove', function () { + beforeAll(() => { + jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => cb()); + jest.useFakeTimers(); + }); + afterAll(() => { + jest.useRealTimers(); + window.requestAnimationFrame.mockRestore(); + }); + + afterEach(() => { + // for restoreTextSelection + jest.runAllTimers(); + }); + + describe('mouse events', function () { + installMouseEvent(); + + it('responds to mouse events', function () { + let events = []; + let addEvent = (e) => events.push(e); + let tree = render( + + ); + + let el = tree.getByTestId(EXAMPLE_ELEMENT_TESTID); + + fireEvent.mouseDown(el, {button: 0, pageX: 1, pageY: 30}); + expect(events).toStrictEqual([]); + fireEvent.mouseMove(el, {button: 0, pageX: 10, pageY: 25}); + expect(events).toStrictEqual([{type: 'movestart', pointerType: 'mouse'}, {type: 'move', pointerType: 'mouse', deltaX: 9, deltaY: -5}]); + fireEvent.mouseUp(el); + expect(events).toStrictEqual([{type: 'movestart', pointerType: 'mouse'}, {type: 'move', pointerType: 'mouse', deltaX: 9, deltaY: -5}, {type: 'moveend', pointerType: 'mouse'}]); + }); + + it('doesn\'t respond to right click', function () { + let events = []; + let addEvent = (e) => events.push(e); + let tree = render( + + ); + + let el = tree.getByTestId(EXAMPLE_ELEMENT_TESTID); + + fireEvent.mouseDown(el, {button: 2, pageX: 1, pageY: 30}); + expect(events).toStrictEqual([]); + fireEvent.mouseMove(el, {button: 2, pageX: 10, pageY: 25}); + expect(events).toStrictEqual([]); + fireEvent.mouseUp(el, {button: 2, pageX: 10, pageY: 25}); + expect(events).toStrictEqual([]); + }); + + it('doesn\'t fire anything when clicking', function () { + let events = []; + let addEvent = (e) => events.push(e); + let tree = render( + + ); + + let el = tree.getByTestId(EXAMPLE_ELEMENT_TESTID); + + fireEvent.mouseDown(el, {button: 0, pageX: 1, pageY: 30}); + fireEvent.mouseUp(el, {button: 0, pageX: 1, pageY: 30}); + expect(events).toStrictEqual([]); + }); + }); + + describe('touch events', function () { + it('responds to touch events', function () { + let events = []; + let addEvent = (e) => events.push(e); + let tree = render( + + ); + + let el = tree.getByTestId(EXAMPLE_ELEMENT_TESTID); + + fireEvent.touchStart(el, {targetTouches: [{identifier: 1, pageX: 1, pageY: 30}]}); + expect(events).toStrictEqual([]); + fireEvent.touchMove(el, {changedTouches: [{identifier: 1, pageX: 10, pageY: 25}]}); + expect(events).toStrictEqual([{type: 'movestart', pointerType: 'touch'}, {type: 'move', pointerType: 'touch', deltaX: 9, deltaY: -5}]); + fireEvent.touchEnd(el, {changedTouches: [{identifier: 1, pageX: 10, pageY: 25}]}); + expect(events).toStrictEqual([{type: 'movestart', pointerType: 'touch'}, {type: 'move', pointerType: 'touch', deltaX: 9, deltaY: -5}, {type: 'moveend', pointerType: 'touch'}]); + }); + + it('ends with touchcancel', function () { + let events = []; + let addEvent = (e) => events.push(e); + let tree = render( + + ); + + let el = tree.getByTestId(EXAMPLE_ELEMENT_TESTID); + + fireEvent.touchStart(el, {targetTouches: [{identifier: 1, pageX: 1, pageY: 30}]}); + expect(events).toStrictEqual([]); + fireEvent.touchMove(el, {changedTouches: [{identifier: 1, pageX: 10, pageY: 25}]}); + expect(events).toStrictEqual([{type: 'movestart', pointerType: 'touch'}, {type: 'move', pointerType: 'touch', deltaX: 9, deltaY: -5}]); + fireEvent.touchCancel(el, {changedTouches: [{identifier: 1, pageX: 10, pageY: 25}]}); + expect(events).toStrictEqual([{type: 'movestart', pointerType: 'touch'}, {type: 'move', pointerType: 'touch', deltaX: 9, deltaY: -5}, {type: 'moveend', pointerType: 'touch'}]); + }); + + it('doesn\'t fire anything when tapping', function () { + let events = []; + let addEvent = (e) => events.push(e); + let tree = render( + + ); + + let el = tree.getByTestId(EXAMPLE_ELEMENT_TESTID); + + fireEvent.touchStart(el, {targetTouches: [{identifier: 1, pageX: 1, pageY: 30}]}); + fireEvent.touchEnd(el, {changedTouches: [{identifier: 1, pageX: 1, pageY: 30}]}); + expect(events).toStrictEqual([]); + }); + }); + + describe('user-select: none', () => { + let mockUserSelect = 'contain'; + let oldUserSelect = document.documentElement.style.webkitUserSelect; + + beforeEach(() => { + document.documentElement.style.webkitUserSelect = mockUserSelect; + }); + afterEach(() => { + document.documentElement.style.webkitUserSelect = oldUserSelect; + }); + + it('adds and removes user-select: none to the body', function () { + let tree = render( + {}} + onMove={() => {}} + onMoveEnd={() => {}} /> + ); + + let el = tree.getByTestId(EXAMPLE_ELEMENT_TESTID); + + expect(document.documentElement.style.webkitUserSelect).toBe(mockUserSelect); + fireEvent.touchStart(el, {targetTouches: [{identifier: 1, pageX: 1, pageY: 30}]}); + expect(document.documentElement.style.webkitUserSelect).toBe('none'); + fireEvent.touchMove(el, {changedTouches: [{identifier: 1, pageX: 10, pageY: 25}]}); + expect(document.documentElement.style.webkitUserSelect).toBe('none'); + fireEvent.touchEnd(el, {changedTouches: [{identifier: 1, pageX: 10, pageY: 25}]}); + expect(document.documentElement.style.webkitUserSelect).toBe('none'); + jest.advanceTimersByTime(300); + expect(document.documentElement.style.webkitUserSelect).toBe(mockUserSelect); + }); + }); + + it('doesn\'t bubble to useMove on parent elements', function () { + let eventsChild = []; + let eventsParent = []; + let addEventChild = (e) => eventsChild.push(e); + let addEventParent = (e) => eventsParent.push(e); + let tree = render( + + + + ); + + let [, el] = tree.getAllByTestId(EXAMPLE_ELEMENT_TESTID); + + fireEvent.touchStart(el, {targetTouches: [{identifier: 1, pageX: 1, pageY: 30}]}); + expect(eventsChild).toStrictEqual([]); + fireEvent.touchMove(el, {changedTouches: [{identifier: 1, pageX: 10, pageY: 25}]}); + expect(eventsChild).toStrictEqual([{type: 'movestart', pointerType: 'touch'}, {type: 'move', pointerType: 'touch', deltaX: 9, deltaY: -5}]); + fireEvent.touchEnd(el, {changedTouches: [{identifier: 1, pageX: 10, pageY: 25}]}); + expect(eventsChild).toStrictEqual([{type: 'movestart', pointerType: 'touch'}, {type: 'move', pointerType: 'touch', deltaX: 9, deltaY: -5}, {type: 'moveend', pointerType: 'touch'}]); + expect(eventsParent).toStrictEqual([]); + + }); + + describe('keypresses', function () { + it.each` + Key | Result + ${'ArrowUp'} | ${{deltaX: 0, deltaY: -1}} + ${'ArrowDown'} | ${{deltaX: 0, deltaY: 1}} + ${'ArrowLeft'} | ${{deltaX: -1, deltaY: 0}} + ${'ArrowRight'} | ${{deltaX: 1, deltaY: 0}} + `('responds to keypresses: $Key', function ({Key, Result}) { + let events = []; + let addEvent = (e) => events.push(e); + let tree = render( + + ); + + let el = tree.getByTestId(EXAMPLE_ELEMENT_TESTID); + + fireEvent.keyDown(el, {key: Key}); + expect(events).toStrictEqual([{type: 'movestart', pointerType: 'keyboard'}, {type: 'move', pointerType: 'keyboard', ...Result}, {type: 'moveend', pointerType: 'keyboard'}]); + }); + + it('allows handling other key events', function () { + let events = []; + let addEvent = (e) => events.push(e); + let tree = render( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
addEvent({type: e.type, key: e.key})}> + +
+ ); + + let el = tree.getByTestId(EXAMPLE_ELEMENT_TESTID); + + fireEvent.keyDown(el, {key: 'PageUp'}); + expect(events).toStrictEqual([{type: 'keydown', key: 'PageUp'}]); + }); + }); + + describe('pointer events', function () { + installPointerEvent(); + + it('responds to pointer events', function () { + let events = []; + let addEvent = (e) => events.push(e); + let tree = render( + + ); + + let el = tree.getByTestId(EXAMPLE_ELEMENT_TESTID); + + fireEvent.pointerDown(el, {pointerType: 'pen', pointerId: 1, pageX: 1, pageY: 30}); + expect(events).toStrictEqual([]); + fireEvent.pointerMove(el, {pointerType: 'pen', pointerId: 1, pageX: 10, pageY: 25}); + expect(events).toStrictEqual([{type: 'movestart', pointerType: 'pen'}, {type: 'move', pointerType: 'pen', deltaX: 9, deltaY: -5}]); + fireEvent.pointerUp(el, {pointerType: 'pen', pointerId: 1}); + expect(events).toStrictEqual([{type: 'movestart', pointerType: 'pen'}, {type: 'move', pointerType: 'pen', deltaX: 9, deltaY: -5}, {type: 'moveend', pointerType: 'pen'}]); + }); + + it('doesn\'t respond to right click', function () { + let events = []; + let addEvent = (e) => events.push(e); + let tree = render( + + ); + + let el = tree.getByTestId(EXAMPLE_ELEMENT_TESTID); + + fireEvent.pointerDown(el, {pointerType: 'pen', pointerId: 1, pageX: 1, pageY: 30, button: 2}); + expect(events).toStrictEqual([]); + fireEvent.pointerMove(el, {pointerType: 'pen', pointerId: 1, pageX: 10, pageY: 25, button: 2}); + expect(events).toStrictEqual([]); + fireEvent.pointerUp(el, {pointerType: 'pen', pointerId: 1, pageX: 10, pageY: 25, button: 2}); + expect(events).toStrictEqual([]); + }); + + it('ends with pointercancel', function () { + let events = []; + let addEvent = (e) => events.push(e); + let tree = render( + + ); + + let el = tree.getByTestId(EXAMPLE_ELEMENT_TESTID); + + fireEvent.pointerDown(el, {pointerType: 'pen', pointerId: 1, pageX: 1, pageY: 30}); + expect(events).toStrictEqual([]); + fireEvent.pointerMove(el, {pointerType: 'pen', pointerId: 1, pageX: 10, pageY: 25}); + expect(events).toStrictEqual([{type: 'movestart', pointerType: 'pen'}, {type: 'move', pointerType: 'pen', deltaX: 9, deltaY: -5}]); + fireEvent.pointerCancel(el, {pointerType: 'pen', pointerId: 1}); + expect(events).toStrictEqual([{type: 'movestart', pointerType: 'pen'}, {type: 'move', pointerType: 'pen', deltaX: 9, deltaY: -5}, {type: 'moveend', pointerType: 'pen'}]); + }); + + it('doesn\'t fire anything when tapping', function () { + let events = []; + let addEvent = (e) => events.push(e); + let tree = render( + + ); + + let el = tree.getByTestId(EXAMPLE_ELEMENT_TESTID); + + fireEvent.pointerDown(el, {pointerType: 'pen', pointerId: 1, pageX: 1, pageY: 30}); + fireEvent.pointerUp(el, {pointerType: 'pen', pointerId: 1, pageX: 1, pageY: 30}); + expect(events).toStrictEqual([]); + }); + }); +}); diff --git a/packages/@react-aria/interactions/test/usePress.test.js b/packages/@react-aria/interactions/test/usePress.test.js index b5132ccb9c5..762f6283416 100644 --- a/packages/@react-aria/interactions/test/usePress.test.js +++ b/packages/@react-aria/interactions/test/usePress.test.js @@ -11,6 +11,7 @@ */ import {fireEvent, render} from '@testing-library/react'; +import {installPointerEvent} from '@react-spectrum/test-utils'; import React from 'react'; import {usePress} from '../'; @@ -49,13 +50,7 @@ describe('usePress', function () { // TODO: JSDOM doesn't yet support pointer events. Once they do, convert these tests. // https://github.com/jsdom/jsdom/issues/2527 describe('pointer events', function () { - beforeEach(() => { - global.PointerEvent = {}; - }); - - afterEach(() => { - delete global.PointerEvent; - }); + installPointerEvent(); it('should fire press events based on pointer events', function () { let events = []; diff --git a/packages/@react-aria/slider/src/useSlider.ts b/packages/@react-aria/slider/src/useSlider.ts index 7644eaa95a5..082133a75c1 100644 --- a/packages/@react-aria/slider/src/useSlider.ts +++ b/packages/@react-aria/slider/src/useSlider.ts @@ -10,13 +10,14 @@ * governing permissions and limitations under the License. */ +import {clamp, mergeProps, useGlobalListeners} from '@react-aria/utils'; import {HTMLAttributes, useRef} from 'react'; -import {mergeProps, useDrag1D} from '@react-aria/utils'; import {sliderIds} from './utils'; import {SliderProps} from '@react-types/slider'; import {SliderState} from '@react-stately/slider'; import {useLabel} from '@react-aria/label'; import {useLocale} from '@react-aria/i18n'; +import {useMove} from '@react-aria/interactions'; interface SliderAria { /** Props for the label element. */ @@ -51,53 +52,89 @@ export function useSlider( let {direction} = useLocale(); + let {addGlobalListener, removeGlobalListener} = useGlobalListeners(); + // When the user clicks or drags the track, we want the motion to set and drag the - // closest thumb. Hence we also need to install useDrag1D() on the track element. + // closest thumb. Hence we also need to install useMove() on the track element. // Here, we keep track of which index is the "closest" to the drag start point. - // It is set onMouseDown; see trackProps below. - const realTimeTrackDraggingIndex = useRef(undefined); - const isTrackDragging = useRef(false); - const {onMouseDown, onMouseEnter, onMouseOut, onKeyDown} = useDrag1D({ - containerRef: trackRef as any, - reverse: direction === 'rtl', - orientation: 'horizontal', - onDrag: (dragging) => { - if (realTimeTrackDraggingIndex.current !== undefined) { - state.setThumbDragging(realTimeTrackDraggingIndex.current, dragging); - } - isTrackDragging.current = dragging; + // It is set onMouseDown/onTouchDown; see trackProps below. + const realTimeTrackDraggingIndex = useRef(null); + + const stateRef = useRef(null); + stateRef.current = state; + const reverseX = direction === 'rtl'; + const currentPosition = useRef(null); + const moveProps = useMove({ + onMoveStart() { + currentPosition.current = null; }, - onPositionChange: (position) => { - if (realTimeTrackDraggingIndex.current !== undefined && trackRef.current) { - const percent = position / trackRef.current.offsetWidth; - state.setThumbPercent(realTimeTrackDraggingIndex.current, percent); - - // When track-dragging ends, onDrag is called before a final onPositionChange is - // called, so we can't reset realTimeTrackDraggingIndex until onPositionChange, - // as we still needed to update the thumb position one last time. Hence we - // track whether we're dragging, and the actual dragged index, separately. - if (!isTrackDragging.current) { - realTimeTrackDraggingIndex.current = undefined; - } + onMove({deltaX}) { + if (currentPosition.current == null) { + currentPosition.current = stateRef.current.getThumbPercent(realTimeTrackDraggingIndex.current) * trackRef.current.offsetWidth; + } + currentPosition.current += reverseX ? -deltaX : deltaX; + + if (realTimeTrackDraggingIndex.current != null && trackRef.current) { + const percent = clamp(currentPosition.current / trackRef.current.offsetWidth, 0, 1); + stateRef.current.setThumbPercent(realTimeTrackDraggingIndex.current, percent); } }, - onIncrement() { - state.setThumbValue(state.focusedThumb, state.getThumbValue(state.focusedThumb) + state.step); - }, - onDecrement() { - state.setThumbValue(state.focusedThumb, state.getThumbValue(state.focusedThumb) - state.step); - }, - onIncrementToMax() { - state.setThumbValue(state.focusedThumb, state.getThumbMaxValue(state.focusedThumb)); - }, - onDecrementToMin() { - state.setThumbValue(state.focusedThumb, state.getThumbMinValue(state.focusedThumb)); + onMoveEnd() { + if (realTimeTrackDraggingIndex.current != null) { + stateRef.current.setThumbDragging(realTimeTrackDraggingIndex.current, false); + realTimeTrackDraggingIndex.current = null; + } } }); + let onDownTrack = (e: React.UIEvent, clientX: number) => { + // We only trigger track-dragging if the user clicks on the track itself and nothing is currently being dragged. + if (trackRef.current && !props.isDisabled && state.values.every((_, i) => !state.isThumbDragging(i))) { + // Find the closest thumb + const trackPosition = trackRef.current.getBoundingClientRect().left; + const clickPosition = clientX; + const offset = clickPosition - trackPosition; + let percent = offset / trackRef.current.offsetWidth; + if (direction === 'rtl') { + percent = 1 - percent; + } + let value = state.getPercentValue(percent); + + // Only compute the diff for thumbs that are editable, as only they can be dragged + const minDiff = Math.min(...state.values.map((v, index) => state.isThumbEditable(index) ? Math.abs(v - value) : Number.POSITIVE_INFINITY)); + const index = state.values.findIndex(v => Math.abs(v - value) === minDiff); + if (minDiff !== Number.POSITIVE_INFINITY && index >= 0) { + // Don't unfocus anything + e.preventDefault(); + + realTimeTrackDraggingIndex.current = index; + state.setFocusedThumb(index); + + state.setThumbDragging(realTimeTrackDraggingIndex.current, true); + state.setThumbValue(index, value); + + addGlobalListener(window, 'mouseup', onUpTrack, false); + addGlobalListener(window, 'touchend', onUpTrack, false); + addGlobalListener(window, 'pointerup', onUpTrack, false); + } else { + realTimeTrackDraggingIndex.current = null; + } + } + }; + + let onUpTrack = () => { + if (realTimeTrackDraggingIndex.current != null) { + state.setThumbDragging(realTimeTrackDraggingIndex.current, false); + realTimeTrackDraggingIndex.current = null; + } + + removeGlobalListener(window, 'mouseup', onUpTrack, false); + removeGlobalListener(window, 'touchend', onUpTrack, false); + removeGlobalListener(window, 'pointerup', onUpTrack, false); + }; + return { labelProps, - // The root element of the Slider will have role="group" to group together // all the thumb inputs in the Slider. The label of the Slider will // be used to label the group. @@ -106,44 +143,9 @@ export function useSlider( ...fieldProps }, trackProps: mergeProps({ - onMouseDown: (e: React.MouseEvent) => { - // We only trigger track-dragging if the user clicks on the track itself. - if (trackRef.current && !props.isDisabled) { - // Find the closest thumb - const trackPosition = trackRef.current.getBoundingClientRect().left; - const clickPosition = e.clientX; - const offset = clickPosition - trackPosition; - let percent = offset / trackRef.current.offsetWidth; - if (direction === 'rtl') { - percent = 1 - percent; - } - const value = state.getPercentValue(percent); - - // Only compute the diff for thumbs that are editable, as only they can be dragged - const minDiff = Math.min(...state.values.map((v, index) => state.isThumbEditable(index) ? Math.abs(v - value) : Number.POSITIVE_INFINITY)); - const index = state.values.findIndex(v => Math.abs(v - value) === minDiff); - if (minDiff !== Number.POSITIVE_INFINITY && index >= 0) { - // Don't unfocus anything - e.preventDefault(); - - realTimeTrackDraggingIndex.current = index; - state.setFocusedThumb(index); - - // We immediately toggle state to dragging and set the value on mouse down. - // We set the value now, instead of waiting for onDrag, so that the thumb - // is updated while you're still holding the mouse button down. And we - // set dragging on now, so that onChangeEnd() won't fire yet when we set - // the value. Dragging state will be reset to false in onDrag above, even - // if no dragging actually occurs. - state.setThumbDragging(realTimeTrackDraggingIndex.current, true); - state.setThumbValue(index, value); - } else { - realTimeTrackDraggingIndex.current = undefined; - } - } - } - }, { - onMouseDown, onMouseEnter, onMouseOut, onKeyDown - }) + onMouseDown(e: React.MouseEvent) { onDownTrack(e, e.clientX); }, + onPointerDown(e: React.PointerEvent) { onDownTrack(e, e.clientX); }, + onTouchStart(e: React.TouchEvent) { onDownTrack(e, e.targetTouches[0].clientX); } + }, moveProps) }; } diff --git a/packages/@react-aria/slider/src/useSliderThumb.ts b/packages/@react-aria/slider/src/useSliderThumb.ts index 90f1dfe2d64..33d6e74dc69 100644 --- a/packages/@react-aria/slider/src/useSliderThumb.ts +++ b/packages/@react-aria/slider/src/useSliderThumb.ts @@ -1,11 +1,12 @@ -import {ChangeEvent, HTMLAttributes, useCallback, useEffect} from 'react'; -import {focusWithoutScrolling, mergeProps, useDrag1D} from '@react-aria/utils'; +import {ChangeEvent, HTMLAttributes, useCallback, useEffect, useRef} from 'react'; +import {clamp, focusWithoutScrolling, mergeProps, useGlobalListeners} from '@react-aria/utils'; import {sliderIds} from './utils'; import {SliderState} from '@react-stately/slider'; import {SliderThumbProps} from '@react-types/slider'; import {useFocusable} from '@react-aria/focus'; import {useLabel} from '@react-aria/label'; import {useLocale} from '@react-aria/i18n'; +import {useMove} from '@react-aria/interactions'; interface SliderThumbAria { /** Props for the range input. */ @@ -43,6 +44,7 @@ export function useSliderThumb( } = opts; let {direction} = useLocale(); + let {addGlobalListener, removeGlobalListener} = useGlobalListeners(); let labelId = sliderIds.get(state); const {labelProps, fieldProps} = useLabel({ @@ -66,17 +68,31 @@ export function useSliderThumb( } }, [isFocused, focusInput]); - const draggableProps = useDrag1D({ - containerRef: trackRef as any, - reverse: direction === 'rtl', - orientation: 'horizontal', - onDrag: (dragging) => { - state.setThumbDragging(index, dragging); - focusInput(); + const stateRef = useRef(null); + stateRef.current = state; + let reverseX = direction === 'rtl'; + let currentPosition = useRef(null); + let moveProps = useMove({ + onMoveStart() { + currentPosition.current = null; + state.setThumbDragging(index, true); + }, + onMove({deltaX, deltaY, pointerType}) { + if (currentPosition.current == null) { + currentPosition.current = stateRef.current.getThumbPercent(index) * trackRef.current.offsetWidth; + } + if (pointerType === 'keyboard') { + // (invert left/right according to language direction) + (up should always increase) + let delta = ((reverseX ? -deltaX : deltaX) + -deltaY) * stateRef.current.step; + currentPosition.current += delta * trackRef.current.offsetWidth; + stateRef.current.setThumbValue(index, stateRef.current.getThumbValue(index) + delta); + } else { + currentPosition.current += reverseX ? -deltaX : deltaX; + stateRef.current.setThumbPercent(index, clamp(currentPosition.current / trackRef.current.offsetWidth, 0, 1)); + } }, - onPositionChange: (position) => { - const percent = position / trackRef.current.offsetWidth; - state.setThumbPercent(index, percent); + onMoveEnd() { + state.setThumbDragging(index, false); } }); @@ -91,6 +107,24 @@ export function useSliderThumb( inputRef ); + let onDown = () => { + focusInput(); + state.setThumbDragging(index, true); + + addGlobalListener(window, 'mouseup', onUp, false); + addGlobalListener(window, 'touchend', onUp, false); + addGlobalListener(window, 'pointerup', onUp, false); + + }; + + let onUp = () => { + focusInput(); + state.setThumbDragging(index, false); + removeGlobalListener(window, 'mouseup', onUp, false); + removeGlobalListener(window, 'touchend', onUp, false); + removeGlobalListener(window, 'pointerup', onUp, false); + }; + // We install mouse handlers for the drag motion on the thumb div, but // not the key handler for moving the thumb with the slider. Instead, // we focus the range input, and let the browser handle the keyboard @@ -113,13 +147,14 @@ export function useSliderThumb( state.setThumbValue(index, parseFloat(e.target.value)); } }), - thumbProps: !isDisabled ? mergeProps({ - onMouseDown: draggableProps.onMouseDown, - onMouseEnter: draggableProps.onMouseEnter, - onMouseOut: draggableProps.onMouseOut - }, { - onMouseDown: focusInput - }) : {}, + thumbProps: !isDisabled ? mergeProps( + moveProps, + { + onPointerDown: onDown, + onMouseDown: onDown, + onTouchStart: onDown + } + ) : {}, labelProps }; } diff --git a/packages/@react-aria/slider/stories/StoryRangeSlider.tsx b/packages/@react-aria/slider/stories/StoryRangeSlider.tsx index 17a4fed7457..8eed2dd6b65 100644 --- a/packages/@react-aria/slider/stories/StoryRangeSlider.tsx +++ b/packages/@react-aria/slider/stories/StoryRangeSlider.tsx @@ -77,9 +77,10 @@ export function StoryRangeSlider(props: StoryRangeSliderProps) { { // We put thumbProps on thumbHandle, so that you cannot drag by the tip } -
+
+ +
{props.showTip &&
{state.getThumbValueLabel(0)}
} -
@@ -93,9 +94,10 @@ export function StoryRangeSlider(props: StoryRangeSliderProps) { // For fun, we put the thumbProps on the thumb container instead of just the handle. // This means you can drag the max thumb by the tip. } -
+
+ +
{props.showTip &&
{state.getThumbValueLabel(1)} (can drag by tip)
} -
diff --git a/packages/@react-aria/slider/stories/StorySlider.tsx b/packages/@react-aria/slider/stories/StorySlider.tsx index c3d07e4517f..fb48eca2baa 100644 --- a/packages/@react-aria/slider/stories/StorySlider.tsx +++ b/packages/@react-aria/slider/stories/StorySlider.tsx @@ -68,9 +68,10 @@ export function StorySlider(props: StorySliderProps) { 'left': `${state.getThumbPercent(0) * 100}%` }}> {/* We put thumbProps on thumbHandle, so that you cannot drag by the tip */} -
+
+ +
{props.showTip &&
{state.getThumbValueLabel(0)}
} -
diff --git a/packages/@react-aria/slider/test/useSlider.test.js b/packages/@react-aria/slider/test/useSlider.test.js index 2fb66c8b78c..818060cbb53 100644 --- a/packages/@react-aria/slider/test/useSlider.test.js +++ b/packages/@react-aria/slider/test/useSlider.test.js @@ -1,4 +1,5 @@ import {fireEvent, render, screen} from '@testing-library/react'; +import {installMouseEvent, installPointerEvent} from '@react-spectrum/test-utils'; import * as React from 'react'; import {renderHook} from '@testing-library/react-hooks'; import {useRef} from 'react'; @@ -51,6 +52,100 @@ describe('useSlider', () => { widthStub.mockReset(); }); + installMouseEvent(); + + let stateRef = React.createRef(); + + function Example(props) { + let trackRef = useRef(null); + let state = useSliderState(props); + stateRef.current = state; + let {trackProps} = useSlider(props, state, trackRef); + return
; + } + + it('should allow you to set value of closest thumb by clicking on track', () => { + let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); + render(); + + let track = screen.getByTestId('track'); + fireEvent.mouseDown(track, {clientX: 20, pageX: 20}); + fireEvent.mouseUp(track, {clientX: 20, pageX: 20}); + + expect(onChangeSpy).toHaveBeenLastCalledWith([20, 80]); + expect(onChangeEndSpy).toHaveBeenLastCalledWith([20, 80]); + expect(stateRef.current.values).toEqual([20, 80]); + + track = screen.getByTestId('track'); + fireEvent.mouseDown(track, {clientX: 90, pageX: 90}); + fireEvent.mouseUp(track, {clientX: 90, pageX: 90}); + + expect(onChangeSpy).toHaveBeenLastCalledWith([20, 90]); + expect(onChangeEndSpy).toHaveBeenLastCalledWith([20, 90]); + expect(stateRef.current.values).toEqual([20, 90]); + }); + + it('should allow you to set value of closest thumb by dragging on track', () => { + let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); + render(); + + let track = screen.getByTestId('track'); + fireEvent.mouseDown(track, {clientX: 20, pageX: 20}); + expect(onChangeSpy).toHaveBeenLastCalledWith([20, 80]); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([20, 80]); + + fireEvent.mouseMove(track, {clientX: 30, pageX: 30}); + expect(onChangeSpy).toHaveBeenLastCalledWith([30, 80]); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([30, 80]); + + fireEvent.mouseMove(track, {clientX: 40, pageX: 40}); + expect(onChangeSpy).toHaveBeenLastCalledWith([40, 80]); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([40, 80]); + + fireEvent.mouseUp(track, {clientX: 40, pageX: 40}); + expect(onChangeEndSpy).toHaveBeenLastCalledWith([40, 80]); + expect(stateRef.current.values).toEqual([40, 80]); + }); + + it('should not allow you to set value if disabled', () => { + let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); + render(); + + let track = screen.getByTestId('track'); + fireEvent.mouseDown(track, {clientX: 20, pageX: 20}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([10, 80]); + + fireEvent.mouseMove(track, {clientX: 30, pageX: 30}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([10, 80]); + + fireEvent.mouseUp(track, {clientX: 40, pageX: 40}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([10, 80]); + }); + }); + + describe('interactions on track using pointerEvents', () => { + let widthStub; + beforeAll(() => { + widthStub = jest.spyOn(window.HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(() => 100); + }); + afterAll(() => { + widthStub.mockReset(); + }); + + installPointerEvent(); + let stateRef = React.createRef(); function Example(props) { @@ -67,16 +162,16 @@ describe('useSlider', () => { render(); let track = screen.getByTestId('track'); - fireEvent.mouseDown(track, {clientX: 20}); - fireEvent.mouseUp(track, {clientX: 20}); + fireEvent.pointerDown(track, {pageX: 20, clientX: 20}); + fireEvent.pointerUp(track, {pageX: 20, clientX: 20}); expect(onChangeSpy).toHaveBeenLastCalledWith([20, 80]); expect(onChangeEndSpy).toHaveBeenLastCalledWith([20, 80]); expect(stateRef.current.values).toEqual([20, 80]); track = screen.getByTestId('track'); - fireEvent.mouseDown(track, {clientX: 90}); - fireEvent.mouseUp(track, {clientX: 90}); + fireEvent.pointerDown(track, {pageX: 90, clientX: 90}); + fireEvent.pointerUp(track, {pageX: 90, clientX: 90}); expect(onChangeSpy).toHaveBeenLastCalledWith([20, 90]); expect(onChangeEndSpy).toHaveBeenLastCalledWith([20, 90]); @@ -86,25 +181,26 @@ describe('useSlider', () => { it('should allow you to set value of closest thumb by dragging on track', () => { let onChangeSpy = jest.fn(); let onChangeEndSpy = jest.fn(); + render(); let track = screen.getByTestId('track'); - fireEvent.mouseDown(track, {clientX: 20}); + fireEvent.pointerDown(track, {pageX: 20, clientX: 20}); expect(onChangeSpy).toHaveBeenLastCalledWith([20, 80]); expect(onChangeEndSpy).not.toHaveBeenCalled(); expect(stateRef.current.values).toEqual([20, 80]); - fireEvent.mouseMove(track, {clientX: 30}); + fireEvent.pointerMove(track, {pageX: 30, clientX: 30}); expect(onChangeSpy).toHaveBeenLastCalledWith([30, 80]); expect(onChangeEndSpy).not.toHaveBeenCalled(); expect(stateRef.current.values).toEqual([30, 80]); - fireEvent.mouseMove(track, {clientX: 40}); + fireEvent.pointerMove(track, {pageX: 40, clientX: 40}); expect(onChangeSpy).toHaveBeenLastCalledWith([40, 80]); expect(onChangeEndSpy).not.toHaveBeenCalled(); expect(stateRef.current.values).toEqual([40, 80]); - fireEvent.mouseUp(track, {clientX: 40}); + fireEvent.pointerUp(track, {pageX: 40, clientX: 40}); expect(onChangeEndSpy).toHaveBeenLastCalledWith([40, 80]); expect(stateRef.current.values).toEqual([40, 80]); }); @@ -115,17 +211,17 @@ describe('useSlider', () => { render(); let track = screen.getByTestId('track'); - fireEvent.mouseDown(track, {clientX: 20}); + fireEvent.pointerDown(track, {pageX: 20, clientX: 20}); expect(onChangeSpy).not.toHaveBeenCalled(); expect(onChangeEndSpy).not.toHaveBeenCalled(); expect(stateRef.current.values).toEqual([10, 80]); - fireEvent.mouseMove(track, {clientX: 30}); + fireEvent.pointerMove(track, {pageX: 30, clientX: 30}); expect(onChangeSpy).not.toHaveBeenCalled(); expect(onChangeEndSpy).not.toHaveBeenCalled(); expect(stateRef.current.values).toEqual([10, 80]); - - fireEvent.mouseUp(track, {clientX: 40}); + + fireEvent.pointerUp(track, {pageX: 40, clientX: 40}); expect(onChangeSpy).not.toHaveBeenCalled(); expect(onChangeEndSpy).not.toHaveBeenCalled(); expect(stateRef.current.values).toEqual([10, 80]); diff --git a/packages/@react-aria/slider/test/useSliderThumb.test.js b/packages/@react-aria/slider/test/useSliderThumb.test.js index c2be36cf1b8..e2dc0c714f5 100644 --- a/packages/@react-aria/slider/test/useSliderThumb.test.js +++ b/packages/@react-aria/slider/test/useSliderThumb.test.js @@ -1,4 +1,5 @@ import {fireEvent, render, screen} from '@testing-library/react'; +import {installMouseEvent, installPointerEvent} from '@react-spectrum/test-utils'; import * as React from 'react'; import {renderHook} from '@testing-library/react-hooks'; import {useRef} from 'react'; @@ -103,6 +104,8 @@ describe('useSliderThumb', () => { widthStub.mockReset(); }); + installMouseEvent(); + let stateRef = React.createRef(); function RangeExample(props) { @@ -139,59 +142,119 @@ describe('useSliderThumb', () => { ); } - it('can be moved by dragging', () => { - let onChangeSpy = jest.fn(); - let onChangeEndSpy = jest.fn(); - render(); - - // Drag thumb0 - let thumb0 = screen.getByTestId('thumb0'); - fireEvent.mouseDown(thumb0, {clientX: 10}); - expect(onChangeSpy).not.toHaveBeenCalled(); - expect(onChangeEndSpy).not.toHaveBeenCalled(); - expect(stateRef.current.values).toEqual([10, 80]); - - fireEvent.mouseMove(thumb0, {clientX: 20}); - expect(onChangeSpy).toHaveBeenLastCalledWith([20, 80]); - expect(onChangeEndSpy).not.toHaveBeenCalled(); - expect(stateRef.current.values).toEqual([20, 80]); - - fireEvent.mouseMove(thumb0, {clientX: 30}); - expect(onChangeSpy).toHaveBeenLastCalledWith([30, 80]); - expect(onChangeEndSpy).not.toHaveBeenCalled(); - expect(stateRef.current.values).toEqual([30, 80]); - - fireEvent.mouseMove(thumb0, {clientX: 40}); - fireEvent.mouseUp(thumb0, {clientX: 40}); - expect(onChangeSpy).toHaveBeenLastCalledWith([40, 80]); - expect(onChangeEndSpy).toHaveBeenLastCalledWith([40, 80]); - expect(stateRef.current.values).toEqual([40, 80]); - - onChangeSpy.mockClear(); - onChangeEndSpy.mockClear(); - - // Drag thumb1 past thumb0 - let thumb1 = screen.getByTestId('thumb1'); - fireEvent.mouseDown(thumb1, {clientX: 80}); - expect(onChangeSpy).not.toHaveBeenCalled(); - expect(onChangeEndSpy).not.toHaveBeenCalled(); - expect(stateRef.current.values).toEqual([40, 80]); - - fireEvent.mouseMove(thumb1, {clientX: 60}); - expect(onChangeSpy).toHaveBeenLastCalledWith([40, 60]); - expect(onChangeEndSpy).not.toHaveBeenCalled(); - expect(stateRef.current.values).toEqual([40, 60]); - - fireEvent.mouseMove(thumb1, {clientX: 30}); - expect(onChangeSpy).toHaveBeenLastCalledWith([40, 40]); - expect(onChangeEndSpy).not.toHaveBeenCalled(); - expect(stateRef.current.values).toEqual([40, 40]); - - fireEvent.mouseUp(thumb1, {clientX: 30}); - expect(onChangeSpy).toHaveBeenLastCalledWith([40, 40]); - expect(onChangeEndSpy).toHaveBeenLastCalledWith([40, 40]); - expect(stateRef.current.values).toEqual([40, 40]); + describe('using PointerEvents', () => { + installPointerEvent(); + + it('can be moved by dragging', () => { + let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); + render(); + + // Drag thumb0 + let thumb0 = screen.getByTestId('thumb0'); + fireEvent.pointerDown(thumb0, {clientX: 10, pageX: 10}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([10, 80]); + + fireEvent.pointerMove(thumb0, {clientX: 20, pageX: 20}); + expect(onChangeSpy).toHaveBeenLastCalledWith([20, 80]); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([20, 80]); + + fireEvent.pointerMove(thumb0, {clientX: 30, pageX: 30}); + expect(onChangeSpy).toHaveBeenLastCalledWith([30, 80]); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([30, 80]); + + fireEvent.pointerMove(thumb0, {clientX: 40, pageX: 40}); + fireEvent.pointerUp(thumb0, {clientX: 40, pageX: 40}); + expect(onChangeSpy).toHaveBeenLastCalledWith([40, 80]); + expect(onChangeEndSpy).toHaveBeenLastCalledWith([40, 80]); + expect(stateRef.current.values).toEqual([40, 80]); + + onChangeSpy.mockClear(); + onChangeEndSpy.mockClear(); + + // Drag thumb1 past thumb0 + let thumb1 = screen.getByTestId('thumb1'); + fireEvent.pointerDown(thumb1, {clientX: 80, pageX: 80}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([40, 80]); + + fireEvent.pointerMove(thumb1, {clientX: 60, pageX: 60}); + expect(onChangeSpy).toHaveBeenLastCalledWith([40, 60]); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([40, 60]); + + fireEvent.pointerMove(thumb1, {clientX: 30, pageX: 30}); + expect(onChangeSpy).toHaveBeenLastCalledWith([40, 40]); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([40, 40]); + + fireEvent.pointerUp(thumb1, {clientX: 30, pageX: 30}); + expect(onChangeSpy).toHaveBeenLastCalledWith([40, 40]); + expect(onChangeEndSpy).toHaveBeenLastCalledWith([40, 40]); + expect(stateRef.current.values).toEqual([40, 40]); + }); + }); + describe('using MouseEvents', () => { + it('can be moved by dragging', () => { + let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); + render(); + + // Drag thumb0 + let thumb0 = screen.getByTestId('thumb0'); + fireEvent.mouseDown(thumb0, {clientX: 10, pageX: 10}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([10, 80]); + + fireEvent.mouseMove(thumb0, {clientX: 20, pageX: 20}); + expect(onChangeSpy).toHaveBeenLastCalledWith([20, 80]); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([20, 80]); + + fireEvent.mouseMove(thumb0, {clientX: 30, pageX: 30}); + expect(onChangeSpy).toHaveBeenLastCalledWith([30, 80]); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([30, 80]); + + fireEvent.mouseMove(thumb0, {clientX: 40, pageX: 40}); + fireEvent.mouseUp(thumb0, {clientX: 40, pageX: 40}); + expect(onChangeSpy).toHaveBeenLastCalledWith([40, 80]); + expect(onChangeEndSpy).toHaveBeenLastCalledWith([40, 80]); + expect(stateRef.current.values).toEqual([40, 80]); + + onChangeSpy.mockClear(); + onChangeEndSpy.mockClear(); + + // Drag thumb1 past thumb0 + let thumb1 = screen.getByTestId('thumb1'); + fireEvent.mouseDown(thumb1, {clientX: 80, pageX: 80}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([40, 80]); + + fireEvent.mouseMove(thumb1, {clientX: 60, pageX: 60}); + expect(onChangeSpy).toHaveBeenLastCalledWith([40, 60]); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([40, 60]); + + fireEvent.mouseMove(thumb1, {clientX: 30, pageX: 30}); + expect(onChangeSpy).toHaveBeenLastCalledWith([40, 40]); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([40, 40]); + + fireEvent.mouseUp(thumb1, {clientX: 30, pageX: 30}); + expect(onChangeSpy).toHaveBeenLastCalledWith([40, 40]); + expect(onChangeEndSpy).toHaveBeenLastCalledWith([40, 40]); + expect(stateRef.current.values).toEqual([40, 40]); + }); }); + }); describe('interactions on thumbs, where track contains thumbs', () => { @@ -202,6 +265,7 @@ describe('useSliderThumb', () => { afterAll(() => { widthStub.mockReset(); }); + installMouseEvent(); let stateRef = React.createRef(); @@ -226,29 +290,77 @@ describe('useSliderThumb', () => { ); } - it('can be moved by dragging', () => { - let onChangeSpy = jest.fn(); - let onChangeEndSpy = jest.fn(); - render(); - - // Drag thumb - let thumb0 = screen.getByTestId('thumb'); - fireEvent.mouseDown(thumb0, {clientX: 10}); - expect(onChangeSpy).not.toHaveBeenCalled(); - expect(onChangeEndSpy).not.toHaveBeenCalled(); - expect(stateRef.current.values).toEqual([10]); - - fireEvent.mouseMove(thumb0, {clientX: 20}); - expect(onChangeSpy).toHaveBeenLastCalledWith([20]); - expect(onChangeEndSpy).not.toHaveBeenCalled(); - expect(stateRef.current.values).toEqual([20]); - - fireEvent.mouseMove(thumb0, {clientX: 40}); - fireEvent.mouseUp(thumb0, {clientX: 40}); - expect(onChangeSpy).toHaveBeenLastCalledWith([40]); - expect(onChangeEndSpy).toHaveBeenLastCalledWith([40]); - expect(stateRef.current.values).toEqual([40]); - expect(onChangeEndSpy).toBeCalledTimes(1); + describe('using PointerEvents', () => { + installPointerEvent(); + + it('can be moved by dragging', () => { + let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); + render(); + + // Drag thumb + let thumb0 = screen.getByTestId('thumb'); + fireEvent.pointerDown(thumb0, {clientX: 10, pageX: 10}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([10]); + + fireEvent.pointerMove(thumb0, {clientX: 20, pageX: 20}); + expect(onChangeSpy).toHaveBeenLastCalledWith([20]); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([20]); + + fireEvent.pointerMove(thumb0, {clientX: 40, pageX: 40}); + fireEvent.pointerUp(thumb0, {clientX: 40, pageX: 40}); + expect(onChangeSpy).toHaveBeenLastCalledWith([40]); + expect(onChangeEndSpy).toHaveBeenLastCalledWith([40]); + expect(stateRef.current.values).toEqual([40]); + expect(onChangeEndSpy).toBeCalledTimes(1); + }); + }); + + describe('using MouseEvents', () => { + it('can be moved by dragging', () => { + let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); + render(); + + // Drag thumb + let thumb0 = screen.getByTestId('thumb'); + fireEvent.mouseDown(thumb0, {clientX: 10, pageX: 10}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([10]); + + fireEvent.mouseMove(thumb0, {clientX: 20, pageX: 20}); + expect(onChangeSpy).toHaveBeenLastCalledWith([20]); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([20]); + + fireEvent.mouseMove(thumb0, {clientX: 40, pageX: 40}); + fireEvent.mouseUp(thumb0, {clientX: 40, pageX: 40}); + expect(onChangeSpy).toHaveBeenLastCalledWith([40]); + expect(onChangeEndSpy).toHaveBeenLastCalledWith([40]); + expect(stateRef.current.values).toEqual([40]); + expect(onChangeEndSpy).toBeCalledTimes(1); + }); + }); + + describe('using KeyEvents', () => { + it('can be moved with keys', () => { + let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); + render(); + + // Drag thumb + let thumb0 = screen.getByTestId('thumb').firstChild; + fireEvent.keyDown(thumb0, {key: 'ArrowRight'}); + expect(onChangeSpy).toHaveBeenLastCalledWith([11]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).toHaveBeenLastCalledWith([11]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(stateRef.current.values).toEqual([11]); + }); }); }); }); diff --git a/packages/@react-aria/splitview/src/useSplitView.ts b/packages/@react-aria/splitview/src/useSplitView.ts index eb290ababac..c181fd76a20 100644 --- a/packages/@react-aria/splitview/src/useSplitView.ts +++ b/packages/@react-aria/splitview/src/useSplitView.ts @@ -10,10 +10,10 @@ * governing permissions and limitations under the License. */ -import {chain} from '@react-aria/utils'; import {HTMLAttributes, useEffect, useRef} from 'react'; -import {SplitViewAriaProps, SplitViewState} from '@react-types/shared'; -import {useDrag1D} from '@react-aria/utils'; +import {mergeProps} from '@react-aria/utils'; +import {SplitViewAriaProps, SplitViewHandleState, SplitViewState} from '@react-types/shared'; +import {useHover, useKeyboard, useMove} from '@react-aria/interactions'; import {useId} from '@react-aria/utils'; interface AriaSplitViewProps { @@ -32,8 +32,7 @@ export function useSplitView(props: SplitViewAriaProps, state: SplitViewState): secondaryMinSize = 304, secondaryMaxSize = Infinity, orientation = 'horizontal' as 'horizontal', - allowsResizing = true, - onMouseDown: propsOnMouseDown + allowsResizing = true } = props; let {containerState, handleState} = state; let id = useId(providedId); @@ -62,36 +61,93 @@ export function useSplitView(props: SplitViewAriaProps, state: SplitViewState): }; }, [containerRef, containerState, primaryMinSize, primaryMaxSize, secondaryMinSize, secondaryMaxSize, orientation, size]); - let draggableProps = useDrag1D({ - containerRef, - reverse, - orientation, - onHover: (hovered) => handleState.setHover(hovered), - onDrag: (dragging) => handleState.setDragging(dragging), - onPositionChange: (position) => handleState.setOffset(position), - onIncrement: () => handleState.increment(), - onDecrement: () => handleState.decrement(), - onIncrementToMax: () => handleState.incrementToMax(), - onDecrementToMin: () => handleState.decrementToMin(), - onCollapseToggle: () => handleState.collapseToggle() + let {hoverProps} = useHover({ + isDisabled: !allowsResizing, + onHoverStart() { + handleState.setHover(true); + }, + onHoverEnd() { + handleState.setHover(false); + } + }); + + const handleStateRef = useRef(null); + handleStateRef.current = handleState; + const currentPosition = useRef(null); + const moveProps = useMove({ + onMoveStart() { + // console.log("start") + handleState.setDragging(true); + currentPosition.current = null; + }, + onMove({deltaX, deltaY, pointerType}) { + if (currentPosition.current == null) { + currentPosition.current = handleState.offset; + } + + let delta = orientation === 'horizontal' ? deltaX : deltaY; + if (reverse) { + delta *= -1; + } + + if (pointerType === 'keyboard') { + if (delta > 0) { + handleState.increment(); + } else if (delta < 0) { + handleState.decrement(); + } + } else { + // console.log("move", delta, currentPosition.current) + currentPosition.current += delta; + + handleState.setOffset(currentPosition.current); + } + }, + onMoveEnd() { + // console.log("end") + handleStateRef.current.setDragging(false); + } }); let ariaValueMin = 0; let ariaValueMax = 100; let ariaValueNow = (handleState.offset - containerState.minPos) / (containerState.maxPos - containerState.minPos) * 100 | 0; - let onMouseDown = allowsResizing ? chain(draggableProps.onMouseDown, propsOnMouseDown) : undefined; - let onMouseEnter = allowsResizing ? draggableProps.onMouseEnter : undefined; - let onMouseOut = allowsResizing ? draggableProps.onMouseOut : undefined; - let onKeyDown = allowsResizing ? draggableProps.onKeyDown : undefined; - let tabIndex = allowsResizing ? 0 : undefined; + let {keyboardProps} = useKeyboard({ + isDisabled: !allowsResizing, + onKeyDown({key}) { + if (key === 'Home') { + handleState.setDragging(true); + handleState.decrementToMin(); + handleState.setDragging(false); + } else if (key === 'End') { + handleState.setDragging(true); + handleState.incrementToMax(); + handleState.setDragging(false); + } else if (key === 'Enter') { + handleState.collapseToggle(); + } + } + }); + + // TODO should `handleState.setDragging` be set on press or on the first move? + let onStart = () => { + handleState.setDragging(true); + document.addEventListener('mouseup', onEnd, false); + document.addEventListener('touchend', onEnd, false); + }; + let onEnd = () => { + handleState.setDragging(false); + document.removeEventListener('mouseup', onEnd, false); + document.removeEventListener('touchend', onEnd, false); + }; return { containerProps: { id }, handleProps: { - tabIndex, + tabIndex: allowsResizing ? 0 : undefined, 'aria-valuenow': ariaValueNow, 'aria-valuemin': ariaValueMin, 'aria-valuemax': ariaValueMax, @@ -99,10 +155,10 @@ export function useSplitView(props: SplitViewAriaProps, state: SplitViewState): 'aria-labelledby': props['aria-labelledby'], role: 'separator', 'aria-controls': id, - onMouseDown, - onMouseEnter, - onMouseOut, - onKeyDown + ...(allowsResizing && mergeProps({ + onMouseDown: onStart, + onTouchStart: onStart + }, moveProps, hoverProps, keyboardProps)) }, primaryPaneProps: { id diff --git a/packages/@react-aria/utils/src/index.ts b/packages/@react-aria/utils/src/index.ts index a448aa9dcd9..ec60e665683 100644 --- a/packages/@react-aria/utils/src/index.ts +++ b/packages/@react-aria/utils/src/index.ts @@ -13,14 +13,15 @@ export * from './useId'; export * from './chain'; export * from './mergeProps'; -export * from './number'; +export * from './filterDOMProps'; +export * from './focusWithoutScrolling'; export * from './getOffset'; +export * from './number'; +export * from './runAfterTransition'; export * from './useDrag1D'; +export * from './useGlobalListeners'; export * from './useLabels'; export * from './useUpdateEffect'; -export * from './focusWithoutScrolling'; -export * from './filterDOMProps'; -export * from './runAfterTransition'; export * from './useLayoutEffect'; export * from './useResizeObserver'; export * from './getScrollParent'; diff --git a/packages/@react-aria/utils/src/useGlobalListeners.ts b/packages/@react-aria/utils/src/useGlobalListeners.ts new file mode 100644 index 00000000000..1ffca89d0d5 --- /dev/null +++ b/packages/@react-aria/utils/src/useGlobalListeners.ts @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {useCallback, useEffect, useRef} from 'react'; + +interface GlobalListeners { + addGlobalListener(el: EventTarget, type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void, + addGlobalListener(el: EventTarget, type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void, + removeGlobalListener(el: EventTarget, type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void, + removeGlobalListener(el: EventTarget, type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void +} + +export function useGlobalListeners(): GlobalListeners { + let globalListeners = useRef(new Map()); + let addGlobalListener = useCallback((eventTarget, type, listener, options) => { + globalListeners.current.set(listener, {type, eventTarget, options}); + eventTarget.addEventListener(type, listener, options); + }, []); + let removeGlobalListener = useCallback((eventTarget, type, listener, options) => { + eventTarget.removeEventListener(type, listener, options); + globalListeners.current.delete(listener); + }, []); + + // eslint-disable-next-line arrow-body-style + useEffect(() => { + return () => { + globalListeners.current.forEach((value, key) => { + removeGlobalListener(value.eventTarget, value.type, key, value.options); + }); + }; + }, [removeGlobalListener]); + + return {addGlobalListener, removeGlobalListener}; +} diff --git a/packages/@react-spectrum/overlays/test/Tray.test.js b/packages/@react-spectrum/overlays/test/Tray.test.js index 35708884e63..251c883f8de 100644 --- a/packages/@react-spectrum/overlays/test/Tray.test.js +++ b/packages/@react-spectrum/overlays/test/Tray.test.js @@ -10,14 +10,17 @@ * governing permissions and limitations under the License. */ +import {act, fireEvent, render, waitFor} from '@testing-library/react'; import {Dialog} from '@react-spectrum/dialog'; -import {fireEvent, render, waitFor} from '@testing-library/react'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; import {theme} from '@react-spectrum/theme-default'; import {Tray} from '../'; describe('Tray', function () { + beforeAll(() => jest.useFakeTimers()); + afterAll(() => jest.useRealTimers()); + it('should render nothing if isOpen is not set', function () { let {getByRole} = render( @@ -60,6 +63,7 @@ describe('Tray', function () { await waitFor(() => { expect(getByRole('dialog')).toBeVisible(); }); // wait for animation + act(() => {jest.runAllTimers();}); let dialog = await getByRole('dialog'); fireEvent.keyDown(dialog, {key: 'Escape'}); @@ -79,6 +83,7 @@ describe('Tray', function () { await waitFor(() => { expect(getByRole('dialog')).toBeVisible(); }); // wait for animation + act(() => {jest.runAllTimers();}); fireEvent.mouseDown(document.body); fireEvent.mouseUp(document.body); @@ -98,11 +103,15 @@ describe('Tray', function () { await waitFor(() => { expect(getByRole('dialog')).toBeVisible(); }); // wait for animation + act(() => {jest.runAllTimers();}); let dialog = await getByRole('dialog'); expect(document.activeElement).toBe(dialog); + // The iOS Safari workaround blurs and refocuses the dialog after 0.5s + expect(onClose).toHaveBeenCalledTimes(1); dialog.blur(); - expect(onClose).toHaveBeenCalledTimes(1); + // (The iOS Safari workaround) + (the actual onClose) = 2 + expect(onClose).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/@react-spectrum/slider/stories/RangeSlider.stories.tsx b/packages/@react-spectrum/slider/stories/RangeSlider.stories.tsx index 151260e0686..9d221699252 100644 --- a/packages/@react-spectrum/slider/stories/RangeSlider.stories.tsx +++ b/packages/@react-spectrum/slider/stories/RangeSlider.stories.tsx @@ -31,7 +31,7 @@ storiesOf('RangeSlider', module) ) .add( 'custom width', - () => render({label: 'Label', width: '200px'}) + () => render({label: 'Label', width: '300px'}) ) .add( 'label overflow', diff --git a/packages/@react-spectrum/slider/stories/Slider.stories.tsx b/packages/@react-spectrum/slider/stories/Slider.stories.tsx index c24a011cfc8..2fd8fec0b77 100644 --- a/packages/@react-spectrum/slider/stories/Slider.stories.tsx +++ b/packages/@react-spectrum/slider/stories/Slider.stories.tsx @@ -11,6 +11,7 @@ */ import {action} from '@storybook/addon-actions'; +import {Flex} from '@adobe/react-spectrum'; import React, {useState} from 'react'; import {Slider} from '../'; import {SpectrumSliderProps} from '@react-types/slider'; @@ -25,6 +26,13 @@ storiesOf('Slider', module) 'label', () => render({label: 'Label'}) ) + .add( + 'multitouch', + () => ( + {render({label: 'Label'})} + {render({label: 'Label'})} + ) + ) .add( 'isDisabled', () => render({label: 'Label', defaultValue: 50, isDisabled: true}) @@ -65,6 +73,10 @@ storiesOf('Slider', module) 'min/max', () => render({label: 'Label', minValue: 30, maxValue: 70}) ) + .add( + 'step', + () => render({label: 'Label', minValue: 0, maxValue: 100, step: 10}) + ) .add( 'isFilled: true', () => render({label: 'Label', isFilled: true}) diff --git a/packages/@react-spectrum/slider/test/RangeSlider.test.tsx b/packages/@react-spectrum/slider/test/RangeSlider.test.tsx index 62ba8416726..37435cbc63a 100644 --- a/packages/@react-spectrum/slider/test/RangeSlider.test.tsx +++ b/packages/@react-spectrum/slider/test/RangeSlider.test.tsx @@ -210,16 +210,13 @@ describe('RangeSlider', function () { }); describe('keyboard interactions', () => { - // Can't test arrow/page up/down arrows because they are handled by the browser and JSDOM doesn't feel like it. + // Can't test arrow/page up/down, home/end arrows because they are handled by the browser and JSDOM doesn't feel like it. it.each` Name | props | commands ${'(left/right arrows, ltr)'} | ${{locale: 'de-DE'}} | ${[{left: press.ArrowRight, result: +1}, {left: press.ArrowLeft, result: -1}, {right: press.ArrowRight, result: +1}, {right: press.ArrowLeft, result: -1}]} ${'(left/right arrows, rtl)'} | ${{locale: 'ar-AE'}} | ${[{left: press.ArrowRight, result: -1}, {left: press.ArrowLeft, result: +1}, {right: press.ArrowRight, result: -1}, {right: press.ArrowLeft, result: +1}]} - ${'(home/end, ltr)'} | ${{locale: 'de-DE'}} | ${[{left: press.End, result: '50'}, {left: press.Home, result: '0'}, {left: press.ArrowRight, result: '1'}, {right: press.Home, result: '1'}, {right: press.End, result: '100'}]} - ${'(home/end, rtl)'} | ${{locale: 'ar-AE'}} | ${[{left: press.End, result: '50'}, {left: press.Home, result: '0'}, {left: press.ArrowLeft, result: '1'}, {right: press.Home, result: '1'}, {right: press.End, result: '100'}]} ${'(left/right arrows, isDisabled)'} | ${{locale: 'de-DE', isDisabled: true}}| ${[{left: press.ArrowRight, result: 0}, {left: press.ArrowLeft, result: 0}, {right: press.ArrowRight, result: 0}, {right: press.ArrowLeft, result: 0}]} - ${'(home/end, isDisabled)'} | ${{locale: 'de-DE', isDisabled: true}}| ${[{left: press.End, result: 0}, {left: press.Home, result: 0}, {right: press.End, result: 0}, {right: press.Home, result: 0}]} `('$Name moves the slider in the correct direction', function ({props, commands}) { let tree = render( @@ -268,6 +265,30 @@ describe('RangeSlider', function () { window.HTMLElement.prototype.offsetWidth.mockReset(); }); + beforeAll(() => { + let oldMouseEvent = MouseEvent; + // @ts-ignore + global.MouseEvent = class FakeMouseEvent extends MouseEvent { + _init: {pageX: number, pageY: number}; + constructor(name, init) { + super(name, init); + this._init = init; + } + get pageX() { + return this._init.pageX; + } + get pageY() { + return this._init.pageY; + } + }; + // @ts-ignore + global.MouseEvent.oldMouseEvent = oldMouseEvent; + }); + afterAll(() => { + // @ts-ignore + global.MouseEvent = global.MouseEvent.oldMouseEvent; + }); + it('can click and drag handle', () => { let onChangeSpy = jest.fn(); let {getAllByRole} = render( @@ -280,36 +301,36 @@ describe('RangeSlider', function () { let [sliderLeft, sliderRight] = getAllByRole('slider'); let [thumbLeft, thumbRight] = [sliderLeft.parentElement.parentElement, sliderRight.parentElement.parentElement]; - fireEvent.mouseDown(thumbLeft, {clientX: 20}); + fireEvent.mouseDown(thumbLeft, {clientX: 20, pageX: 20}); expect(onChangeSpy).not.toHaveBeenCalled(); expect(document.activeElement).toBe(sliderLeft); - fireEvent.mouseMove(thumbLeft, {clientX: 10}); + fireEvent.mouseMove(thumbLeft, {pageX: 10}); expect(onChangeSpy).toHaveBeenCalledTimes(1); expect(onChangeSpy).toHaveBeenLastCalledWith({start: 10, end: 50}); - fireEvent.mouseMove(thumbLeft, {clientX: -10}); + fireEvent.mouseMove(thumbLeft, {pageX: -10}); expect(onChangeSpy).toHaveBeenCalledTimes(2); expect(onChangeSpy).toHaveBeenLastCalledWith({start: 0, end: 50}); - fireEvent.mouseMove(thumbLeft, {clientX: 120}); + fireEvent.mouseMove(thumbLeft, {pageX: 120}); expect(onChangeSpy).toHaveBeenCalledTimes(3); expect(onChangeSpy).toHaveBeenLastCalledWith({start: 50, end: 50}); - fireEvent.mouseUp(thumbLeft, {clientX: 120}); + fireEvent.mouseUp(thumbLeft, {pageX: 120}); expect(onChangeSpy).toHaveBeenCalledTimes(3); onChangeSpy.mockClear(); - fireEvent.mouseDown(thumbRight, {clientX: 50}); + fireEvent.mouseDown(thumbRight, {clientX: 50, pageX: 50}); expect(onChangeSpy).not.toHaveBeenCalled(); expect(document.activeElement).toBe(sliderRight); - fireEvent.mouseMove(thumbRight, {clientX: 60}); + fireEvent.mouseMove(thumbRight, {pageX: 60}); expect(onChangeSpy).toHaveBeenCalledTimes(1); expect(onChangeSpy).toHaveBeenLastCalledWith({start: 50, end: 60}); - fireEvent.mouseMove(thumbRight, {clientX: -10}); + fireEvent.mouseMove(thumbRight, {pageX: -10}); expect(onChangeSpy).toHaveBeenCalledTimes(2); expect(onChangeSpy).toHaveBeenLastCalledWith({start: 50, end: 50}); - fireEvent.mouseMove(thumbRight, {clientX: 120}); + fireEvent.mouseMove(thumbRight, {pageX: 120}); expect(onChangeSpy).toHaveBeenCalledTimes(3); expect(onChangeSpy).toHaveBeenLastCalledWith({start: 50, end: 100}); - fireEvent.mouseUp(thumbRight, {clientX: 120}); + fireEvent.mouseUp(thumbRight, {pageX: 120}); expect(onChangeSpy).toHaveBeenCalledTimes(3); }); @@ -326,30 +347,30 @@ describe('RangeSlider', function () { let [sliderLeft, sliderRight] = getAllByRole('slider'); let [thumbLeft, thumbRight] = [sliderLeft.parentElement.parentElement, sliderRight.parentElement.parentElement]; - fireEvent.mouseDown(thumbLeft, {clientX: 20}); + fireEvent.mouseDown(thumbLeft, {clientX: 20, pageX: 20}); expect(onChangeSpy).not.toHaveBeenCalled(); expect(document.activeElement).not.toBe(sliderLeft); - fireEvent.mouseMove(thumbLeft, {clientX: 10}); + fireEvent.mouseMove(thumbLeft, {pageX: 10}); expect(onChangeSpy).not.toHaveBeenCalled(); - fireEvent.mouseMove(thumbLeft, {clientX: -10}); + fireEvent.mouseMove(thumbLeft, {pageX: -10}); expect(onChangeSpy).not.toHaveBeenCalled(); - fireEvent.mouseMove(thumbLeft, {clientX: 120}); + fireEvent.mouseMove(thumbLeft, {pageX: 120}); expect(onChangeSpy).not.toHaveBeenCalled(); - fireEvent.mouseUp(thumbLeft, {clientX: 120}); + fireEvent.mouseUp(thumbLeft, {pageX: 120}); expect(onChangeSpy).not.toHaveBeenCalled(); onChangeSpy.mockClear(); - fireEvent.mouseDown(thumbRight, {clientX: 50}); + fireEvent.mouseDown(thumbRight, {clientX: 50, pageX: 20}); expect(onChangeSpy).not.toHaveBeenCalled(); expect(document.activeElement).not.toBe(sliderRight); - fireEvent.mouseMove(thumbRight, {clientX: 60}); + fireEvent.mouseMove(thumbRight, {pageX: 60}); expect(onChangeSpy).not.toHaveBeenCalled(); - fireEvent.mouseMove(thumbRight, {clientX: -10}); + fireEvent.mouseMove(thumbRight, {pageX: -10}); expect(onChangeSpy).not.toHaveBeenCalled(); - fireEvent.mouseMove(thumbRight, {clientX: 120}); + fireEvent.mouseMove(thumbRight, {pageX: 120}); expect(onChangeSpy).not.toHaveBeenCalled(); - fireEvent.mouseUp(thumbRight, {clientX: 120}); + fireEvent.mouseUp(thumbRight, {pageX: 120}); expect(onChangeSpy).not.toHaveBeenCalled(); }); @@ -369,38 +390,38 @@ describe('RangeSlider', function () { let [leftTrack, middleTrack, rightTrack] = [...thumbLeft.parentElement.children].filter(c => c !== thumbLeft && c !== thumbRight); // left track - fireEvent.mouseDown(leftTrack, {clientX: 20}); + fireEvent.mouseDown(leftTrack, {clientX: 20, pageX: 20}); expect(document.activeElement).toBe(sliderLeft); expect(onChangeSpy).toHaveBeenCalledTimes(1); expect(onChangeSpy).toHaveBeenLastCalledWith({start: 20, end: 70}); - fireEvent.mouseUp(thumbLeft, {clientX: 20}); + fireEvent.mouseUp(thumbLeft, {pageX: 20}); expect(onChangeSpy).toHaveBeenCalledTimes(1); // middle track, near left slider onChangeSpy.mockClear(); - fireEvent.mouseDown(middleTrack, {clientX: 40}); + fireEvent.mouseDown(middleTrack, {clientX: 40, pageX: 40}); expect(document.activeElement).toBe(sliderLeft); expect(onChangeSpy).toHaveBeenCalledTimes(1); expect(onChangeSpy).toHaveBeenLastCalledWith({start: 40, end: 70}); - fireEvent.mouseUp(thumbLeft, {clientX: 40}); + fireEvent.mouseUp(thumbLeft, {pageX: 40}); expect(onChangeSpy).toHaveBeenCalledTimes(1); // middle track, near right slider onChangeSpy.mockClear(); - fireEvent.mouseDown(middleTrack, {clientX: 60}); + fireEvent.mouseDown(middleTrack, {clientX: 60, pageX: 40}); expect(document.activeElement).toBe(sliderRight); expect(onChangeSpy).toHaveBeenCalledTimes(1); expect(onChangeSpy).toHaveBeenLastCalledWith({start: 40, end: 60}); - fireEvent.mouseUp(thumbRight, {clientX: 60}); + fireEvent.mouseUp(thumbRight, {pageX: 60}); expect(onChangeSpy).toHaveBeenCalledTimes(1); // right track onChangeSpy.mockClear(); - fireEvent.mouseDown(rightTrack, {clientX: 90}); + fireEvent.mouseDown(rightTrack, {clientX: 90, pageX: 90}); expect(document.activeElement).toBe(sliderRight); expect(onChangeSpy).toHaveBeenCalledTimes(1); expect(onChangeSpy).toHaveBeenLastCalledWith({start: 40, end: 90}); - fireEvent.mouseUp(thumbRight, {clientX: 90}); + fireEvent.mouseUp(thumbRight, {pageX: 90}); expect(onChangeSpy).toHaveBeenCalledTimes(1); }); @@ -421,34 +442,34 @@ describe('RangeSlider', function () { let [leftTrack, middleTrack, rightTrack] = [...thumbLeft.parentElement.children].filter(c => c !== thumbLeft && c !== thumbRight); // left track - fireEvent.mouseDown(leftTrack, {clientX: 20}); + fireEvent.mouseDown(leftTrack, {clientX: 20, pageX: 20}); expect(document.activeElement).not.toBe(sliderLeft); expect(onChangeSpy).not.toHaveBeenCalled(); - fireEvent.mouseUp(thumbLeft, {clientX: 20}); + fireEvent.mouseUp(thumbLeft, {pageX: 20}); expect(onChangeSpy).not.toHaveBeenCalled(); // middle track, near left slider onChangeSpy.mockClear(); - fireEvent.mouseDown(middleTrack, {clientX: 40}); + fireEvent.mouseDown(middleTrack, {clientX: 40, pageX: 40}); expect(document.activeElement).not.toBe(sliderLeft); expect(onChangeSpy).not.toHaveBeenCalled(); - fireEvent.mouseUp(thumbLeft, {clientX: 40}); + fireEvent.mouseUp(thumbLeft, {pageX: 40}); expect(onChangeSpy).not.toHaveBeenCalled(); // middle track, near right slider onChangeSpy.mockClear(); - fireEvent.mouseDown(middleTrack, {clientX: 60}); + fireEvent.mouseDown(middleTrack, {clientX: 60, pageX: 60}); expect(document.activeElement).not.toBe(sliderRight); expect(onChangeSpy).not.toHaveBeenCalled(); - fireEvent.mouseUp(thumbRight, {clientX: 60}); + fireEvent.mouseUp(thumbRight, {pageX: 60}); expect(onChangeSpy).not.toHaveBeenCalled(); // right track onChangeSpy.mockClear(); - fireEvent.mouseDown(rightTrack, {clientX: 90}); + fireEvent.mouseDown(rightTrack, {clientX: 90, pageX: 90}); expect(document.activeElement).not.toBe(sliderRight); expect(onChangeSpy).not.toHaveBeenCalled(); - fireEvent.mouseUp(thumbRight, {clientX: 90}); + fireEvent.mouseUp(thumbRight, {pageX: 90}); expect(onChangeSpy).not.toHaveBeenCalled(); }); }); diff --git a/packages/@react-spectrum/slider/test/Slider.test.tsx b/packages/@react-spectrum/slider/test/Slider.test.tsx index 130ba7f9780..a1d6c4f23e0 100644 --- a/packages/@react-spectrum/slider/test/Slider.test.tsx +++ b/packages/@react-spectrum/slider/test/Slider.test.tsx @@ -11,6 +11,7 @@ */ import {act, fireEvent, render} from '@testing-library/react'; +import {installMouseEvent} from '@react-spectrum/test-utils'; import {press, testKeypresses} from './utils'; import {Provider} from '@adobe/react-spectrum'; import React, {useState} from 'react'; @@ -191,16 +192,13 @@ describe('Slider', function () { }); describe('keyboard interactions', () => { - // Can't test arrow/page up/down arrows because they are handled by the browser and JSDOM doesn't feel like it. + // Can't test arrow/page up/down, home/end arrows because they are handled by the browser and JSDOM doesn't feel like it. it.each` Name | props | commands ${'(left/right arrows, ltr)'} | ${{locale: 'de-DE'}} | ${[{left: press.ArrowRight, result: +1}, {left: press.ArrowLeft, result: -1}]} ${'(left/right arrows, rtl)'} | ${{locale: 'ar-AE'}} | ${[{left: press.ArrowRight, result: -1}, {left: press.ArrowLeft, result: +1}]} - ${'(home/end, ltr)'} | ${{locale: 'de-DE'}} | ${[{left: press.End, result: '100'}, {left: press.Home, result: '0'}]} - ${'(home/end, rtl)'} | ${{locale: 'ar-AE'}} | ${[{left: press.End, result: '100'}, {left: press.Home, result: '0'}]} ${'(left/right arrows, isDisabled)'} | ${{locale: 'de-DE', isDisabled: true}}| ${[{left: press.ArrowRight, result: 0}, {left: press.ArrowLeft, result: 0}]} - ${'(home/end, isDisabled)'} | ${{locale: 'de-DE', isDisabled: true}}| ${[{left: press.End, result: 0}, {left: press.Home, result: 0}]} `('$Name moves the slider in the correct direction', function ({props, commands}) { let tree = render( @@ -249,6 +247,8 @@ describe('Slider', function () { window.HTMLElement.prototype.offsetWidth.mockReset(); }); + installMouseEvent(); + it('can click and drag handle', () => { let onChangeSpy = jest.fn(); let {getByRole} = render( @@ -260,20 +260,20 @@ describe('Slider', function () { let slider = getByRole('slider'); let thumb = slider.parentElement; - fireEvent.mouseDown(thumb, {clientX: 50}); + fireEvent.mouseDown(thumb, {clientX: 50, pageX: 50}); expect(onChangeSpy).not.toHaveBeenCalled(); expect(document.activeElement).toBe(slider); - fireEvent.mouseMove(thumb, {clientX: 10}); + fireEvent.mouseMove(thumb, {pageX: 10}); expect(onChangeSpy).toHaveBeenCalledTimes(1); expect(onChangeSpy).toHaveBeenLastCalledWith(10); - fireEvent.mouseMove(thumb, {clientX: -10}); + fireEvent.mouseMove(thumb, {pageX: -10}); expect(onChangeSpy).toHaveBeenCalledTimes(2); expect(onChangeSpy).toHaveBeenLastCalledWith(0); - fireEvent.mouseMove(thumb, {clientX: 120}); + fireEvent.mouseMove(thumb, {pageX: 120}); expect(onChangeSpy).toHaveBeenCalledTimes(3); expect(onChangeSpy).toHaveBeenLastCalledWith(100); - fireEvent.mouseUp(thumb, {clientX: 120}); + fireEvent.mouseUp(thumb, {pageX: 120}); expect(onChangeSpy).toHaveBeenCalledTimes(3); }); @@ -289,12 +289,12 @@ describe('Slider', function () { let slider = getByRole('slider'); let thumb = slider.parentElement; - fireEvent.mouseDown(thumb, {clientX: 50}); + fireEvent.mouseDown(thumb, {clientX: 50, pageX: 50}); expect(onChangeSpy).not.toHaveBeenCalled(); expect(document.activeElement).not.toBe(slider); - fireEvent.mouseMove(thumb, {clientX: 10}); + fireEvent.mouseMove(thumb, {pageX: 10}); expect(onChangeSpy).not.toHaveBeenCalled(); - fireEvent.mouseUp(thumb, {clientX: 10}); + fireEvent.mouseUp(thumb, {pageX: 10}); expect(onChangeSpy).not.toHaveBeenCalled(); }); @@ -313,20 +313,20 @@ describe('Slider', function () { let [leftTrack, rightTrack] = [...thumb.parentElement.children].filter(c => c !== thumb); // left track - fireEvent.mouseDown(leftTrack, {clientX: 20}); + fireEvent.mouseDown(leftTrack, {clientX: 20, pageX: 20}); expect(document.activeElement).toBe(slider); expect(onChangeSpy).toHaveBeenCalledTimes(1); expect(onChangeSpy).toHaveBeenLastCalledWith(20); - fireEvent.mouseUp(thumb, {clientX: 20}); + fireEvent.mouseUp(thumb, {pageX: 20}); expect(onChangeSpy).toHaveBeenCalledTimes(1); // right track onChangeSpy.mockClear(); - fireEvent.mouseDown(rightTrack, {clientX: 70}); + fireEvent.mouseDown(rightTrack, {clientX: 70, pageX: 70}); expect(document.activeElement).toBe(slider); expect(onChangeSpy).toHaveBeenCalledTimes(1); expect(onChangeSpy).toHaveBeenLastCalledWith(70); - fireEvent.mouseUp(thumb, {clientX: 70}); + fireEvent.mouseUp(thumb, {pageX: 70}); expect(onChangeSpy).toHaveBeenCalledTimes(1); }); @@ -346,19 +346,59 @@ describe('Slider', function () { let [leftTrack, rightTrack] = [...thumb.parentElement.children].filter(c => c !== thumb); // left track - fireEvent.mouseDown(leftTrack, {clientX: 20}); + fireEvent.mouseDown(leftTrack, {clientX: 20, pageX: 20}); expect(document.activeElement).not.toBe(slider); expect(onChangeSpy).not.toHaveBeenCalled(); - fireEvent.mouseUp(thumb, {clientX: 20}); + fireEvent.mouseUp(thumb, {pageX: 20}); expect(onChangeSpy).not.toHaveBeenCalled(); // right track onChangeSpy.mockClear(); - fireEvent.mouseDown(rightTrack, {clientX: 70}); + fireEvent.mouseDown(rightTrack, {clientX: 70, pageX: 70}); expect(document.activeElement).not.toBe(slider); expect(onChangeSpy).not.toHaveBeenCalled(); - fireEvent.mouseUp(thumb, {clientX: 70}); + fireEvent.mouseUp(thumb, {pageX: 70}); expect(onChangeSpy).not.toHaveBeenCalled(); }); }); + + describe('touch interactions', () => { + beforeAll(() => { + jest.spyOn(window.HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(() => 100); + }); + afterAll(() => { + // @ts-ignore + window.HTMLElement.prototype.offsetWidth.mockReset(); + }); + + it('doesn\'t jump to second touch on track while already dragging', () => { + let onChangeSpy = jest.fn(); + let {getByRole} = render( + + ); + + let slider = getByRole('slider'); + let thumb = slider.parentElement.parentElement; + // @ts-ignore + let [, rightTrack] = [...thumb.parentElement.children].filter(c => c !== thumb); + + fireEvent.touchStart(thumb, {targetTouches: [{identifier: 1, clientX: 50, pageX: 50}]}); + expect(onChangeSpy).toHaveBeenCalledTimes(0); + + fireEvent.touchStart(rightTrack, {targetTouches: [{identifier: 2, clientX: 60, pageX: 60}]}); + fireEvent.touchMove(rightTrack, {changedTouches: [{identifier: 2, clientX: 70, pageX: 70}]}); + fireEvent.touchEnd(rightTrack, {changedTouches: [{identifier: 2, clientX: 70, pageX: 70}]}); + expect(onChangeSpy).toHaveBeenCalledTimes(0); + + fireEvent.touchMove(thumb, {changedTouches: [{identifier: 1, clientX: 30, pageX: 30}]}); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeSpy).toHaveBeenLastCalledWith(30); + + fireEvent.touchEnd(thumb, {changedTouches: [{identifier: 1, clientX: 30, pageX: 30}]}); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/@react-spectrum/splitview/stories/SplitView.stories.js b/packages/@react-spectrum/splitview/stories/SplitView.stories.js index d906d553821..c7cca48ffcf 100644 --- a/packages/@react-spectrum/splitview/stories/SplitView.stories.js +++ b/packages/@react-spectrum/splitview/stories/SplitView.stories.js @@ -134,15 +134,6 @@ storiesOf('SplitView', module) ) ) - .add( - 'onMouseDown', - () => ( - -
Primary
-
Secondary
-
- ) - ) .add( 'with scrolling content', () => ( diff --git a/packages/@react-spectrum/splitview/test/SplitView.test.js b/packages/@react-spectrum/splitview/test/SplitView.test.js index 0354bc3ca13..6ea4575afd0 100644 --- a/packages/@react-spectrum/splitview/test/SplitView.test.js +++ b/packages/@react-spectrum/splitview/test/SplitView.test.js @@ -11,513 +11,1018 @@ */ import {fireEvent, render} from '@testing-library/react'; +import {installMouseEvent, installPointerEvent} from '@react-spectrum/test-utils'; import React from 'react'; import {SplitView} from '../'; -import V2SplitView from '@react/react-spectrum/SplitView'; describe('SplitView tests', function () { - // Stub offsetWidth/offsetHeight so we can calculate min/max sizes correctly - let stub1, stub2; - beforeAll(function () { - stub1 = jest.spyOn(window.HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(() => 1000); - stub2 = jest.spyOn(window.HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(() => 1000); - }); + describe('use MouseEvent', function () { - afterAll(function () { - stub1.mockReset(); - stub2.mockReset(); - }); - afterEach(function () { - document.body.style.cursor = null; - }); + // Stub offsetWidth/offsetHeight so we can calculate min/max sizes correctly + let stub1, stub2; + beforeAll(function () { + stub1 = jest.spyOn(window.HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(() => 1000); + stub2 = jest.spyOn(window.HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(() => 1000); + }); - it.each` - Name | Component | props - ${'SplitView'} | ${SplitView} | ${{UNSAFE_className: 'splitview'}} - ${'V2SplitView'} | ${V2SplitView} | ${{className: 'splitview'}} - `('$Name handles defaults', async function ({Component, props}) { - let onResizeSpy = jest.fn(); - let onResizeEndSpy = jest.fn(); - let {getByRole} = render( - -
Left
-
Right
-
- ); - - let splitview = document.querySelector('.splitview'); - splitview.getBoundingClientRect = jest.fn(() => ({left: 0, right: 1000})); - let splitSeparator = getByRole('separator'); - let primaryPane = splitview.childNodes[0]; - expect(splitview.childNodes[1]).toEqual(splitSeparator); - expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); - let id = primaryPane.getAttribute('id'); - expect(splitSeparator).toHaveAttribute('aria-controls', id); - expect(splitSeparator).toHaveAttribute('tabindex', '0'); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); - expect(splitSeparator).toHaveAttribute('aria-valuemin', '0'); - expect(splitSeparator).toHaveAttribute('aria-valuemax', '100'); - expect(onResizeSpy).not.toHaveBeenCalled(); - expect(onResizeEndSpy).not.toHaveBeenCalled(); - - let clientY = 20; // arbitrary - // move mouse over to 310 and verify that the size changed to 310 - fireEvent.mouseEnter(splitSeparator, {clientX: 304, clientY}); - fireEvent.mouseMove(splitSeparator, {clientX: 304, clientY}); - expect(document.body.style.cursor).toBe('e-resize'); - fireEvent.mouseDown(splitSeparator, {clientX: 304, clientY, button: 0}); - fireEvent.mouseMove(splitSeparator, {clientX: 307, clientY}); // extra move so cursor change flushes - expect(onResizeSpy).toHaveBeenLastCalledWith(307); - fireEvent.mouseMove(splitSeparator, {clientX: 310, clientY}); - expect(onResizeSpy).toHaveBeenLastCalledWith(310); - expect(document.body.style.cursor).toBe('ew-resize'); - fireEvent.mouseUp(splitSeparator, {clientX: 310, clientY, button: 0}); - expect(onResizeEndSpy).toHaveBeenLastCalledWith(310); - expect(primaryPane).toHaveAttribute('style', 'width: 310px;'); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '1'); - - // move mouse to the far end so that it maxes out 1000px - secondaryMin(304px) = 696px - // visual state: primary is maxed out, secondary is at minimum, mouse is beyond the container width - fireEvent.mouseEnter(splitSeparator, {clientX: 310, clientY}); - fireEvent.mouseMove(splitSeparator, {clientX: 310, clientY}); - expect(document.body.style.cursor).toBe('ew-resize'); - fireEvent.mouseDown(splitSeparator, {clientX: 310, clientY, button: 0}); - fireEvent.mouseMove(splitSeparator, {clientX: 1001, clientY}); - expect(onResizeSpy).toHaveBeenLastCalledWith(696); - fireEvent.mouseUp(splitSeparator, {clientX: 1001, clientY, button: 0}); - expect(onResizeEndSpy).toHaveBeenLastCalledWith(696); - expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); - - // move mouse so we shrink to the far left for minimum, non-collapisble = 304px; - // visual state: primary is at minimum size, secondary is maxed out, mouse is to the left of the split by a lot - fireEvent.mouseEnter(splitSeparator, {clientX: 696, clientY}); - fireEvent.mouseMove(splitSeparator, {clientX: 696, clientY}); - expect(document.body.style.cursor).toBe('w-resize'); - fireEvent.mouseDown(splitSeparator, {clientX: 696, clientY, button: 0}); - fireEvent.mouseMove(splitSeparator, {clientX: 0, clientY}); - expect(onResizeSpy).toHaveBeenLastCalledWith(304); - fireEvent.mouseUp(splitSeparator, {clientX: 0, clientY, button: 0}); - expect(onResizeEndSpy).toHaveBeenLastCalledWith(304); - expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); - - // use keyboard to navigate right 304px + 10px = 314px - fireEvent.keyDown(splitSeparator, {key: 'Right'}); - expect(onResizeSpy).toHaveBeenLastCalledWith(314); - expect(onResizeEndSpy).toHaveBeenLastCalledWith(314); - expect(primaryPane).toHaveAttribute('style', 'width: 314px;'); - fireEvent.keyUp(splitSeparator, {key: 'Right'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '2'); - - // use keyboard to navigate left 314px - 10px = 304px - fireEvent.keyDown(splitSeparator, {key: 'Left'}); - expect(onResizeSpy).toHaveBeenLastCalledWith(304); - expect(onResizeEndSpy).toHaveBeenLastCalledWith(304); - expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); - fireEvent.keyUp(splitSeparator, {key: 'Left'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); - - // use keyboard to navigate left a second time should do nothing - let countResizeEndCall = onResizeEndSpy.mock.calls.length; - fireEvent.keyDown(splitSeparator, {key: 'Left'}); - expect(onResizeEndSpy.mock.calls.length).toBe(countResizeEndCall); - expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); - fireEvent.keyUp(splitSeparator, {key: 'Left'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); - - // use keyboard to navigate up shouldn't move - fireEvent.keyDown(splitSeparator, {key: 'Up'}); - expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); - fireEvent.keyUp(splitSeparator, {key: 'Up'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); - - // use keyboard to navigate down shouldn't move - fireEvent.keyDown(splitSeparator, {key: 'Down'}); - expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); - fireEvent.keyUp(splitSeparator, {key: 'Down'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); - - // use keyboard to navigate End should maximize the primary view -> 696px - fireEvent.keyDown(splitSeparator, {key: 'End'}); - expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); - fireEvent.keyUp(splitSeparator, {key: 'End'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); - - // use keyboard to navigate right at max size should do nothing - fireEvent.keyDown(splitSeparator, {key: 'Right'}); - expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); - fireEvent.keyUp(splitSeparator, {key: 'Right'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); - - // use keyboard to navigate Home should minimize the primary view -> 304px - fireEvent.keyDown(splitSeparator, {key: 'Home'}); - expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); - fireEvent.keyUp(splitSeparator, {key: 'Home'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); - - // use keyboard to navigate Enter should do nothing because default does not allow collapsings - fireEvent.keyDown(splitSeparator, {key: 'Enter'}); - expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); - fireEvent.keyUp(splitSeparator, {key: 'Enter'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); - - // use keyboard to navigate right 304px + 10px = 314px and then use Enter, should do nothing - fireEvent.keyDown(splitSeparator, {key: 'Right'}); - expect(primaryPane).toHaveAttribute('style', 'width: 314px;'); - fireEvent.keyUp(splitSeparator, {key: 'Right'}); - fireEvent.keyDown(splitSeparator, {key: 'Enter'}); - expect(primaryPane).toHaveAttribute('style', 'width: 314px;'); - fireEvent.keyUp(splitSeparator, {key: 'Enter'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '2'); - }); + afterAll(function () { + stub1.mockReset(); + stub2.mockReset(); + }); - it.each` - Name | Component | props - ${'SplitView'} | ${SplitView} | ${{UNSAFE_className: 'splitview'}} - ${'V2SplitView'} | ${V2SplitView} | ${{className: 'splitview'}} - `('$Name handles primaryPane being second', function ({Component, props}) { - let {getByRole} = render( - -
Left
-
Right
-
- ); - - let splitview = document.querySelector('.splitview'); - splitview.getBoundingClientRect = jest.fn(() => ({left: 0, right: 1000})); - let splitSeparator = getByRole('separator'); - let primaryPane = splitview.childNodes[2]; - expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); - let id = primaryPane.getAttribute('id'); - expect(splitSeparator).toHaveAttribute('aria-controls', id); - expect(splitSeparator).toHaveAttribute('tabindex', '0'); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); - expect(splitSeparator).toHaveAttribute('aria-valuemin', '0'); - expect(splitSeparator).toHaveAttribute('aria-valuemax', '100'); - expect(document.body.style.cursor).toBe(''); - - // primary as second means mouse needs to go to 696px to get the handle - // move mouse over to 670 and verify that the size changed to 1000px - 670px = 330px - fireEvent.mouseEnter(splitSeparator, {clientX: 696, clientY: 20}); - fireEvent.mouseMove(splitSeparator, {clientX: 696, clientY: 20}); - expect(document.body.style.cursor).toBe('w-resize'); - fireEvent.mouseDown(splitSeparator, {clientX: 696, clientY: 20, button: 0}); - fireEvent.mouseMove(splitSeparator, {clientX: 680, clientY: 20}); - fireEvent.mouseMove(splitSeparator, {clientX: 670, clientY: 20}); - fireEvent.mouseUp(splitSeparator, {clientX: 670, clientY: 20, button: 0}); - expect(primaryPane).toHaveAttribute('style', 'width: 330px;'); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '6'); - - // move mouse to the far end so that it maxes out 1000px - secondaryMin(304px) = 696px - fireEvent.mouseEnter(splitSeparator, {clientX: 670, clientY: 20}); - fireEvent.mouseMove(splitSeparator, {clientX: 670, clientY: 20}); - expect(document.body.style.cursor).toBe('ew-resize'); - fireEvent.mouseDown(splitSeparator, {clientX: 670, clientY: 20, button: 0}); - fireEvent.mouseMove(splitSeparator, {clientX: 0, clientY: 20}); - fireEvent.mouseUp(splitSeparator, {clientX: 0, clientY: 20, button: 0}); - expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); - - // use keyboard to navigate right 696px - 10px = 686px - fireEvent.keyDown(splitSeparator, {key: 'Right'}); - expect(primaryPane).toHaveAttribute('style', 'width: 686px;'); - fireEvent.keyUp(splitSeparator, {key: 'Right'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '97'); - - // use keyboard to navigate left 686px + 10px = 696px - fireEvent.keyDown(splitSeparator, {key: 'Left'}); - expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); - fireEvent.keyUp(splitSeparator, {key: 'Left'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); - - // use keyboard to navigate Home should minimize the primary view -> 304px - fireEvent.keyDown(splitSeparator, {key: 'Home'}); - expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); - fireEvent.keyUp(splitSeparator, {key: 'Home'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); - - // use keyboard to navigate right at min size should do nothing - fireEvent.keyDown(splitSeparator, {key: 'Right'}); - expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); - fireEvent.keyUp(splitSeparator, {key: 'Right'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); - - // use keyboard to navigate End should maximize the primary view -> 696px - fireEvent.keyDown(splitSeparator, {key: 'End'}); - expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); - fireEvent.keyUp(splitSeparator, {key: 'End'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); - - // use keyboard to navigate left at max size should do nothing - fireEvent.keyDown(splitSeparator, {key: 'Left'}); - expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); - fireEvent.keyUp(splitSeparator, {key: 'Left'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); - }); + afterEach(function () { + document.body.style.cursor = null; + }); + installMouseEvent(); - it.each` - Name | Component | props - ${'SplitView'} | ${SplitView} | ${{allowsCollapsing: true, UNSAFE_className: 'splitview'}} - ${'V2SplitView'} | ${V2SplitView} | ${{collapsible: true, className: 'splitview'}} - `('$Name handles allowsCollapsing', function ({Component, props}) { - let {getByRole} = render( - -
Left
-
Right
-
- ); - - let splitview = document.querySelector('.splitview'); - splitview.getBoundingClientRect = jest.fn(() => ({left: 0, right: 1000})); - let splitSeparator = getByRole('separator'); - let primaryPane = splitview.childNodes[0]; - expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); - let id = primaryPane.getAttribute('id'); - expect(splitSeparator).toHaveAttribute('aria-controls', id); - expect(splitSeparator).toHaveAttribute('tabindex', '0'); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); - expect(splitSeparator).toHaveAttribute('aria-valuemin', '0'); - expect(splitSeparator).toHaveAttribute('aria-valuemax', '100'); - expect(document.body.style.cursor).toBe(''); - - // move mouse over to 310 and verify that the size changed - fireEvent.mouseEnter(splitSeparator, {clientX: 304, clientY: 20}); - fireEvent.mouseMove(splitSeparator, {clientX: 304, clientY: 20}); - expect(document.body.style.cursor).toBe('e-resize'); - fireEvent.mouseDown(splitSeparator, {clientX: 304, clientY: 20, button: 0}); - fireEvent.mouseMove(splitSeparator, {clientX: 310, clientY: 20}); - fireEvent.mouseUp(splitSeparator, {clientX: 310, clientY: 20, button: 0}); - expect(primaryPane).toHaveAttribute('style', 'width: 310px;'); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '1'); - - // move mouse to the far end so that it maxes out 1000px - secondaryMin(304px) = 696px - fireEvent.mouseEnter(splitSeparator, {clientX: 310, clientY: 20}); - fireEvent.mouseMove(splitSeparator, {clientX: 310, clientY: 20}); - expect(document.body.style.cursor).toBe('ew-resize'); - fireEvent.mouseDown(splitSeparator, {clientX: 310, clientY: 20, button: 0}); - fireEvent.mouseMove(splitSeparator, {clientX: 1001, clientY: 20}); - fireEvent.mouseUp(splitSeparator, {clientX: 1001, clientY: 20, button: 0}); - expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); - - // move mouse so we shrink to the collapse point 304px - 50px threshold - 1px = 253px - fireEvent.mouseEnter(splitSeparator, {clientX: 696, clientY: 20}); - fireEvent.mouseMove(splitSeparator, {clientX: 696, clientY: 20}); - expect(document.body.style.cursor).toBe('w-resize'); - fireEvent.mouseDown(splitSeparator, {clientX: 696, clientY: 20, button: 0}); - fireEvent.mouseMove(splitSeparator, {clientX: 253, clientY: 20}); - fireEvent.mouseUp(splitSeparator, {clientX: 253, clientY: 20, button: 0}); - expect(primaryPane).toHaveAttribute('style', 'width: 0px;'); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '-77'); - - // move mouse so we recover from the collapsing - fireEvent.mouseEnter(splitSeparator, {clientX: 0, clientY: 20}); - fireEvent.mouseMove(splitSeparator, {clientX: 0, clientY: 20}); - expect(document.body.style.cursor).toBe('e-resize'); - fireEvent.mouseDown(splitSeparator, {clientX: 0, clientY: 20, button: 0}); - fireEvent.mouseMove(splitSeparator, {clientX: 254, clientY: 20}); - fireEvent.mouseUp(splitSeparator, {clientX: 254, clientY: 20, button: 0}); - expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); - - // use keyboard to navigate right 304px + 10px = 314px - fireEvent.keyDown(splitSeparator, {key: 'Right'}); - expect(primaryPane).toHaveAttribute('style', 'width: 314px;'); - fireEvent.keyUp(splitSeparator, {key: 'Right'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '2'); - - // use keyboard to navigate left 314px - 10px = 304px - fireEvent.keyDown(splitSeparator, {key: 'Left'}); - expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); - fireEvent.keyUp(splitSeparator, {key: 'Left'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); - - // use keyboard to navigate left a second time should do nothing - fireEvent.keyDown(splitSeparator, {key: 'Left'}); - expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); - fireEvent.keyUp(splitSeparator, {key: 'Left'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); - - // use keyboard to navigate up shouldn't move - fireEvent.keyDown(splitSeparator, {key: 'Up'}); - expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); - fireEvent.keyUp(splitSeparator, {key: 'Up'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); - - // use keyboard to navigate down shouldn't move - fireEvent.keyDown(splitSeparator, {key: 'Down'}); - expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); - fireEvent.keyUp(splitSeparator, {key: 'Down'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); - - // use keyboard to navigate End should maximize the primary view -> 696px - fireEvent.keyDown(splitSeparator, {key: 'End'}); - expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); - fireEvent.keyUp(splitSeparator, {key: 'End'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); - - // use keyboard to navigate right at max should do nothing - fireEvent.keyDown(splitSeparator, {key: 'Right'}); - expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); - fireEvent.keyUp(splitSeparator, {key: 'Right'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); - - // use keyboard to navigate Home should minimize the primary view, b/c of allows collapsing -> 0px - fireEvent.keyDown(splitSeparator, {key: 'Home'}); - expect(primaryPane).toHaveAttribute('style', 'width: 0px;'); - fireEvent.keyUp(splitSeparator, {key: 'Home'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '-77'); - - // reset us to max size -> 696px - fireEvent.keyDown(splitSeparator, {key: 'End'}); - expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); - fireEvent.keyUp(splitSeparator, {key: 'End'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); - - // collapse us with Enter - fireEvent.keyDown(splitSeparator, {key: 'Enter'}); - expect(primaryPane).toHaveAttribute('style', 'width: 0px;'); - fireEvent.keyUp(splitSeparator, {key: 'Enter'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '-77'); - - // use keyboard to navigate Right should restore it to the last size - fireEvent.keyDown(splitSeparator, {key: 'Enter'}); - expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); - fireEvent.keyUp(splitSeparator, {key: 'Enter'}); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); - }); + it.each` + Name | Component | props + ${'SplitView'} | ${SplitView} | ${{UNSAFE_className: 'splitview'}} + `('$Name handles defaults', async function ({Component, props}) { + let onResizeSpy = jest.fn(); + let onResizeEndSpy = jest.fn(); + let {getByRole} = render( + +
Left
+
Right
+
+ ); - it.each` - Name | Component | props - ${'SplitView'} | ${SplitView} | ${{UNSAFE_className: 'splitview'}} - ${'V2SplitView'} | ${V2SplitView} | ${{className: 'splitview'}} - `('$Name should render a vertical split view', function ({Component, props}) { - let {getByRole} = render( - -
Left
-
Right
-
- ); - - let splitview = document.querySelector('.splitview'); - splitview.getBoundingClientRect = jest.fn(() => ({top: 0, bottom: 1000})); - let splitSeparator = getByRole('separator'); - let primaryPane = splitview.childNodes[0]; - expect(primaryPane).toHaveAttribute('style', 'height: 304px;'); - let id = primaryPane.getAttribute('id'); - expect(splitSeparator).toHaveAttribute('aria-controls', id); - expect(splitSeparator).toHaveAttribute('tabindex', '0'); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); - expect(splitSeparator).toHaveAttribute('aria-valuemin', '0'); - expect(splitSeparator).toHaveAttribute('aria-valuemax', '100'); - - // move mouse over to 310 and verify that the size changed - fireEvent.mouseEnter(splitSeparator, {clientX: 20, clientY: 304}); - fireEvent.mouseMove(splitSeparator, {clientX: 20, clientY: 304}); - expect(document.body.style.cursor).toBe('s-resize'); - fireEvent.mouseDown(splitSeparator, {clientX: 20, clientY: 304, button: 0}); - fireEvent.mouseMove(splitSeparator, {clientX: 20, clientY: 307}); // extra move so cursor change flushes - fireEvent.mouseMove(splitSeparator, {clientX: 20, clientY: 310}); - expect(document.body.style.cursor).toBe('ns-resize'); - fireEvent.mouseUp(splitSeparator, {clientX: 20, clientY: 310, button: 0}); - expect(primaryPane).toHaveAttribute('style', 'height: 310px;'); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '1'); - - // move mouse to the far end so that it maxes out 1000px - secondaryMin(304px) = 696px - fireEvent.mouseEnter(splitSeparator, {clientX: 20, clientY: 310}); - fireEvent.mouseMove(splitSeparator, {clientX: 20, clientY: 310}); - expect(document.body.style.cursor).toBe('ns-resize'); - fireEvent.mouseDown(splitSeparator, {clientX: 20, clientY: 310, button: 0}); - fireEvent.mouseMove(splitSeparator, {clientX: 20, clientY: 1001}); - fireEvent.mouseUp(splitSeparator, {clientX: 20, clientY: 1001, button: 0}); - expect(primaryPane).toHaveAttribute('style', 'height: 696px;'); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); - - // move mouse so we shrink to the far left for minimum, non-collapisble = 304px; - fireEvent.mouseEnter(splitSeparator, {clientX: 20, clientY: 696}); - fireEvent.mouseMove(splitSeparator, {clientX: 20, clientY: 696}); - expect(document.body.style.cursor).toBe('n-resize'); - fireEvent.mouseDown(splitSeparator, {clientX: 20, clientY: 696, button: 0}); - fireEvent.mouseMove(splitSeparator, {clientX: 20, clientY: 0}); - fireEvent.mouseUp(splitSeparator, {clientX: 20, clientY: 0, button: 0}); - expect(primaryPane).toHaveAttribute('style', 'height: 304px;'); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); - }); + let splitview = document.querySelector('.splitview'); + splitview.getBoundingClientRect = jest.fn(() => ({left: 0, right: 1000})); + let splitSeparator = getByRole('separator'); + let primaryPane = splitview.childNodes[0]; + expect(splitview.childNodes[1]).toEqual(splitSeparator); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + let id = primaryPane.getAttribute('id'); + expect(splitSeparator).toHaveAttribute('aria-controls', id); + expect(splitSeparator).toHaveAttribute('tabindex', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuemin', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuemax', '100'); + expect(onResizeSpy).not.toHaveBeenCalled(); + expect(onResizeEndSpy).not.toHaveBeenCalled(); + + let pageY = 20; // arbitrary + // move mouse over to 310 and verify that the size changed to 310 + fireEvent.mouseEnter(splitSeparator, {pageX: 304, pageY}); + fireEvent.mouseMove(splitSeparator, {pageX: 304, pageY}); + expect(document.body.style.cursor).toBe('e-resize'); + fireEvent.mouseDown(splitSeparator, {pageX: 304, pageY, button: 0}); + fireEvent.mouseMove(splitSeparator, {pageX: 307, pageY}); // extra move so cursor change flushes + expect(onResizeSpy).toHaveBeenLastCalledWith(307); + fireEvent.mouseMove(splitSeparator, {pageX: 310, pageY}); + expect(onResizeSpy).toHaveBeenLastCalledWith(310); + expect(document.body.style.cursor).toBe('ew-resize'); + fireEvent.mouseUp(splitSeparator, {pageX: 310, pageY, button: 0}); + expect(onResizeEndSpy).toHaveBeenLastCalledWith(310); + expect(primaryPane).toHaveAttribute('style', 'width: 310px;'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '1'); + + // move mouse to the far end so that it maxes out 1000px - secondaryMin(304px) = 696px + // visual state: primary is maxed out, secondary is at minimum, mouse is beyond the container width + fireEvent.mouseEnter(splitSeparator, {pageX: 310, pageY}); + fireEvent.mouseMove(splitSeparator, {pageX: 310, pageY}); + expect(document.body.style.cursor).toBe('ew-resize'); + fireEvent.mouseDown(splitSeparator, {pageX: 310, pageY, button: 0}); + fireEvent.mouseMove(splitSeparator, {pageX: 1001, pageY}); + expect(onResizeSpy).toHaveBeenLastCalledWith(696); + fireEvent.mouseUp(splitSeparator, {pageX: 1001, pageY, button: 0}); + expect(onResizeEndSpy).toHaveBeenLastCalledWith(696); + expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); + + // move mouse so we shrink to the far left for minimum, non-collapisble = 304px; + // visual state: primary is at minimum size, secondary is maxed out, mouse is to the left of the split by a lot + fireEvent.mouseEnter(splitSeparator, {pageX: 696, pageY}); + fireEvent.mouseMove(splitSeparator, {pageX: 696, pageY}); + expect(document.body.style.cursor).toBe('w-resize'); + fireEvent.mouseDown(splitSeparator, {pageX: 696, pageY, button: 0}); + fireEvent.mouseMove(splitSeparator, {pageX: 0, pageY}); + expect(onResizeSpy).toHaveBeenLastCalledWith(304); + fireEvent.mouseUp(splitSeparator, {pageX: 0, pageY, button: 0}); + expect(onResizeEndSpy).toHaveBeenLastCalledWith(304); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate right 304px + 10px = 314px + fireEvent.keyDown(splitSeparator, {key: 'Right'}); + expect(onResizeSpy).toHaveBeenLastCalledWith(314); + expect(onResizeEndSpy).toHaveBeenLastCalledWith(314); + expect(primaryPane).toHaveAttribute('style', 'width: 314px;'); + fireEvent.keyUp(splitSeparator, {key: 'Right'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '2'); + + // use keyboard to navigate left 314px - 10px = 304px + fireEvent.keyDown(splitSeparator, {key: 'Left'}); + expect(onResizeSpy).toHaveBeenLastCalledWith(304); + expect(onResizeEndSpy).toHaveBeenLastCalledWith(304); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + fireEvent.keyUp(splitSeparator, {key: 'Left'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate left a second time should do nothing + let countResizeEndCall = onResizeEndSpy.mock.calls.length; + fireEvent.keyDown(splitSeparator, {key: 'Left'}); + expect(onResizeEndSpy.mock.calls.length).toBe(countResizeEndCall); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + fireEvent.keyUp(splitSeparator, {key: 'Left'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate up shouldn't move + fireEvent.keyDown(splitSeparator, {key: 'Up'}); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + fireEvent.keyUp(splitSeparator, {key: 'Up'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate down shouldn't move + fireEvent.keyDown(splitSeparator, {key: 'Down'}); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + fireEvent.keyUp(splitSeparator, {key: 'Down'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate End should maximize the primary view -> 696px + fireEvent.keyDown(splitSeparator, {key: 'End'}); + expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); + fireEvent.keyUp(splitSeparator, {key: 'End'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); + + // use keyboard to navigate right at max size should do nothing + fireEvent.keyDown(splitSeparator, {key: 'Right'}); + expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); + fireEvent.keyUp(splitSeparator, {key: 'Right'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); + + // use keyboard to navigate Home should minimize the primary view -> 304px + fireEvent.keyDown(splitSeparator, {key: 'Home'}); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + fireEvent.keyUp(splitSeparator, {key: 'Home'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate Enter should do nothing because default does not allow collapsings + fireEvent.keyDown(splitSeparator, {key: 'Enter'}); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + fireEvent.keyUp(splitSeparator, {key: 'Enter'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate right 304px + 10px = 314px and then use Enter, should do nothing + fireEvent.keyDown(splitSeparator, {key: 'Right'}); + expect(primaryPane).toHaveAttribute('style', 'width: 314px;'); + fireEvent.keyUp(splitSeparator, {key: 'Right'}); + fireEvent.keyDown(splitSeparator, {key: 'Enter'}); + expect(primaryPane).toHaveAttribute('style', 'width: 314px;'); + fireEvent.keyUp(splitSeparator, {key: 'Enter'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '2'); + }); + + it.each` + Name | Component | props + ${'SplitView'} | ${SplitView} | ${{UNSAFE_className: 'splitview'}} + `('$Name handles primaryPane being second', function ({Component, props}) { + let {getByRole} = render( + +
Left
+
Right
+
+ ); + + let splitview = document.querySelector('.splitview'); + splitview.getBoundingClientRect = jest.fn(() => ({left: 0, right: 1000})); + let splitSeparator = getByRole('separator'); + let primaryPane = splitview.childNodes[2]; + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + let id = primaryPane.getAttribute('id'); + expect(splitSeparator).toHaveAttribute('aria-controls', id); + expect(splitSeparator).toHaveAttribute('tabindex', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuemin', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuemax', '100'); + expect(document.body.style.cursor).toBe(''); + + // primary as second means mouse needs to go to 696px to get the handle + // move mouse over to 670 and verify that the size changed to 1000px - 670px = 330px + fireEvent.mouseEnter(splitSeparator, {pageX: 696, pageY: 20}); + fireEvent.mouseMove(splitSeparator, {pageX: 696, pageY: 20}); + expect(document.body.style.cursor).toBe('w-resize'); + fireEvent.mouseDown(splitSeparator, {pageX: 696, pageY: 20, button: 0}); + fireEvent.mouseMove(splitSeparator, {pageX: 680, pageY: 20}); + fireEvent.mouseMove(splitSeparator, {pageX: 670, pageY: 20}); + fireEvent.mouseUp(splitSeparator, {pageX: 670, pageY: 20, button: 0}); + expect(primaryPane).toHaveAttribute('style', 'width: 330px;'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '6'); + + // move mouse to the far end so that it maxes out 1000px - secondaryMin(304px) = 696px + fireEvent.mouseEnter(splitSeparator, {pageX: 670, pageY: 20}); + fireEvent.mouseMove(splitSeparator, {pageX: 670, pageY: 20}); + expect(document.body.style.cursor).toBe('ew-resize'); + fireEvent.mouseDown(splitSeparator, {pageX: 670, pageY: 20, button: 0}); + fireEvent.mouseMove(splitSeparator, {pageX: 0, pageY: 20}); + fireEvent.mouseUp(splitSeparator, {pageX: 0, pageY: 20, button: 0}); + expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); + + // use keyboard to navigate right 696px - 10px = 686px + fireEvent.keyDown(splitSeparator, {key: 'Right'}); + expect(primaryPane).toHaveAttribute('style', 'width: 686px;'); + fireEvent.keyUp(splitSeparator, {key: 'Right'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '97'); + + // use keyboard to navigate left 686px + 10px = 696px + fireEvent.keyDown(splitSeparator, {key: 'Left'}); + expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); + fireEvent.keyUp(splitSeparator, {key: 'Left'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); + + // use keyboard to navigate Home should minimize the primary view -> 304px + fireEvent.keyDown(splitSeparator, {key: 'Home'}); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + fireEvent.keyUp(splitSeparator, {key: 'Home'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate right at min size should do nothing + fireEvent.keyDown(splitSeparator, {key: 'Right'}); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + fireEvent.keyUp(splitSeparator, {key: 'Right'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate End should maximize the primary view -> 696px + fireEvent.keyDown(splitSeparator, {key: 'End'}); + expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); + fireEvent.keyUp(splitSeparator, {key: 'End'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); + + // use keyboard to navigate left at max size should do nothing + fireEvent.keyDown(splitSeparator, {key: 'Left'}); + expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); + fireEvent.keyUp(splitSeparator, {key: 'Left'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); + }); + + + it.each` + Name | Component | props + ${'SplitView'} | ${SplitView} | ${{allowsCollapsing: true, UNSAFE_className: 'splitview'}} + `('$Name handles allowsCollapsing', function ({Component, props}) { + let {getByRole} = render( + +
Left
+
Right
+
+ ); + + let splitview = document.querySelector('.splitview'); + splitview.getBoundingClientRect = jest.fn(() => ({left: 0, right: 1000})); + let splitSeparator = getByRole('separator'); + let primaryPane = splitview.childNodes[0]; + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + let id = primaryPane.getAttribute('id'); + expect(splitSeparator).toHaveAttribute('aria-controls', id); + expect(splitSeparator).toHaveAttribute('tabindex', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuemin', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuemax', '100'); + expect(document.body.style.cursor).toBe(''); + + // move mouse over to 310 and verify that the size changed + fireEvent.mouseEnter(splitSeparator, {pageX: 304, pageY: 20}); + fireEvent.mouseMove(splitSeparator, {pageX: 304, pageY: 20}); + expect(document.body.style.cursor).toBe('e-resize'); + fireEvent.mouseDown(splitSeparator, {pageX: 304, pageY: 20, button: 0}); + fireEvent.mouseMove(splitSeparator, {pageX: 310, pageY: 20}); + fireEvent.mouseUp(splitSeparator, {pageX: 310, pageY: 20, button: 0}); + expect(primaryPane).toHaveAttribute('style', 'width: 310px;'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '1'); + + // move mouse to the far end so that it maxes out 1000px - secondaryMin(304px) = 696px + fireEvent.mouseEnter(splitSeparator, {pageX: 310, pageY: 20}); + fireEvent.mouseMove(splitSeparator, {pageX: 310, pageY: 20}); + expect(document.body.style.cursor).toBe('ew-resize'); + fireEvent.mouseDown(splitSeparator, {pageX: 310, pageY: 20, button: 0}); + fireEvent.mouseMove(splitSeparator, {pageX: 1001, pageY: 20}); + fireEvent.mouseUp(splitSeparator, {pageX: 1001, pageY: 20, button: 0}); + expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); + + // move mouse so we shrink to the collapse point 304px - 50px threshold - 1px = 253px + fireEvent.mouseEnter(splitSeparator, {pageX: 696, pageY: 20}); + fireEvent.mouseMove(splitSeparator, {pageX: 696, pageY: 20}); + expect(document.body.style.cursor).toBe('w-resize'); + fireEvent.mouseDown(splitSeparator, {pageX: 696, pageY: 20, button: 0}); + fireEvent.mouseMove(splitSeparator, {pageX: 253, pageY: 20}); + fireEvent.mouseUp(splitSeparator, {pageX: 253, pageY: 20, button: 0}); + expect(primaryPane).toHaveAttribute('style', 'width: 0px;'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '-77'); + + // move mouse so we recover from the collapsing + fireEvent.mouseEnter(splitSeparator, {pageX: 0, pageY: 20}); + fireEvent.mouseMove(splitSeparator, {pageX: 0, pageY: 20}); + expect(document.body.style.cursor).toBe('e-resize'); + fireEvent.mouseDown(splitSeparator, {pageX: 0, pageY: 20, button: 0}); + fireEvent.mouseMove(splitSeparator, {pageX: 254, pageY: 20}); + fireEvent.mouseUp(splitSeparator, {pageX: 254, pageY: 20, button: 0}); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate right 304px + 10px = 314px + fireEvent.keyDown(splitSeparator, {key: 'Right'}); + expect(primaryPane).toHaveAttribute('style', 'width: 314px;'); + fireEvent.keyUp(splitSeparator, {key: 'Right'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '2'); + + // use keyboard to navigate left 314px - 10px = 304px + fireEvent.keyDown(splitSeparator, {key: 'Left'}); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + fireEvent.keyUp(splitSeparator, {key: 'Left'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate left a second time should do nothing + fireEvent.keyDown(splitSeparator, {key: 'Left'}); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + fireEvent.keyUp(splitSeparator, {key: 'Left'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate up shouldn't move + fireEvent.keyDown(splitSeparator, {key: 'Up'}); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + fireEvent.keyUp(splitSeparator, {key: 'Up'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate down shouldn't move + fireEvent.keyDown(splitSeparator, {key: 'Down'}); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + fireEvent.keyUp(splitSeparator, {key: 'Down'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate End should maximize the primary view -> 696px + fireEvent.keyDown(splitSeparator, {key: 'End'}); + expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); + fireEvent.keyUp(splitSeparator, {key: 'End'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); + + // use keyboard to navigate right at max should do nothing + fireEvent.keyDown(splitSeparator, {key: 'Right'}); + expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); + fireEvent.keyUp(splitSeparator, {key: 'Right'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); + + // use keyboard to navigate Home should minimize the primary view, b/c of allows collapsing -> 0px + fireEvent.keyDown(splitSeparator, {key: 'Home'}); + expect(primaryPane).toHaveAttribute('style', 'width: 0px;'); + fireEvent.keyUp(splitSeparator, {key: 'Home'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '-77'); + + // reset us to max size -> 696px + fireEvent.keyDown(splitSeparator, {key: 'End'}); + expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); + fireEvent.keyUp(splitSeparator, {key: 'End'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); + + // collapse us with Enter + fireEvent.keyDown(splitSeparator, {key: 'Enter'}); + expect(primaryPane).toHaveAttribute('style', 'width: 0px;'); + fireEvent.keyUp(splitSeparator, {key: 'Enter'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '-77'); + + // use keyboard to navigate Right should restore it to the last size + fireEvent.keyDown(splitSeparator, {key: 'Enter'}); + expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); + fireEvent.keyUp(splitSeparator, {key: 'Enter'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); + }); + + it.each` + Name | Component | props + ${'SplitView'} | ${SplitView} | ${{UNSAFE_className: 'splitview'}} + `('$Name should render a vertical split view', function ({Component, props}) { + let {getByRole} = render( + +
Left
+
Right
+
+ ); + + let splitview = document.querySelector('.splitview'); + splitview.getBoundingClientRect = jest.fn(() => ({top: 0, bottom: 1000})); + let splitSeparator = getByRole('separator'); + let primaryPane = splitview.childNodes[0]; + expect(primaryPane).toHaveAttribute('style', 'height: 304px;'); + let id = primaryPane.getAttribute('id'); + expect(splitSeparator).toHaveAttribute('aria-controls', id); + expect(splitSeparator).toHaveAttribute('tabindex', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuemin', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuemax', '100'); + + // move mouse over to 310 and verify that the size changed + fireEvent.mouseEnter(splitSeparator, {pageX: 20, pageY: 304}); + fireEvent.mouseMove(splitSeparator, {pageX: 20, pageY: 304}); + expect(document.body.style.cursor).toBe('s-resize'); + fireEvent.mouseDown(splitSeparator, {pageX: 20, pageY: 304, button: 0}); + fireEvent.mouseMove(splitSeparator, {pageX: 20, pageY: 307}); // extra move so cursor change flushes + fireEvent.mouseMove(splitSeparator, {pageX: 20, pageY: 310}); + expect(document.body.style.cursor).toBe('ns-resize'); + fireEvent.mouseUp(splitSeparator, {pageX: 20, pageY: 310, button: 0}); + expect(primaryPane).toHaveAttribute('style', 'height: 310px;'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '1'); + + // move mouse to the far end so that it maxes out 1000px - secondaryMin(304px) = 696px + fireEvent.mouseEnter(splitSeparator, {pageX: 20, pageY: 310}); + fireEvent.mouseMove(splitSeparator, {pageX: 20, pageY: 310}); + expect(document.body.style.cursor).toBe('ns-resize'); + fireEvent.mouseDown(splitSeparator, {pageX: 20, pageY: 310, button: 0}); + fireEvent.mouseMove(splitSeparator, {pageX: 20, pageY: 1001}); + fireEvent.mouseUp(splitSeparator, {pageX: 20, pageY: 1001, button: 0}); + expect(primaryPane).toHaveAttribute('style', 'height: 696px;'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); + + // move mouse so we shrink to the far left for minimum, non-collapisble = 304px; + fireEvent.mouseEnter(splitSeparator, {pageX: 20, pageY: 696}); + fireEvent.mouseMove(splitSeparator, {pageX: 20, pageY: 696}); + expect(document.body.style.cursor).toBe('n-resize'); + fireEvent.mouseDown(splitSeparator, {pageX: 20, pageY: 696, button: 0}); + fireEvent.mouseMove(splitSeparator, {pageX: 20, pageY: 0}); + fireEvent.mouseUp(splitSeparator, {pageX: 20, pageY: 0, button: 0}); + expect(primaryPane).toHaveAttribute('style', 'height: 304px;'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + }); + + + it.each` + Name | Component | props + ${'SplitView'} | ${SplitView} | ${{allowsResizing: false, UNSAFE_className: 'splitview'}} + `('$Name can be non-resizable', async function ({Component, props}) { + let {getByRole} = render( + +
Left
+
Right
+
+ ); + + let splitview = document.querySelector('.splitview'); + splitview.getBoundingClientRect = jest.fn(() => ({left: 0, right: 1000})); + let splitSeparator = getByRole('separator'); + let primaryPane = splitview.childNodes[0]; + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + let id = primaryPane.getAttribute('id'); + expect(splitSeparator).toHaveAttribute('aria-controls', id); + expect(splitSeparator).not.toHaveAttribute('tabindex'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuemin', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuemax', '100'); + + // move mouse over to 310 and verify that the size changed + fireEvent.mouseEnter(splitSeparator, {pageX: 304, pageY: 20}); + fireEvent.mouseMove(splitSeparator, {pageX: 304, pageY: 20}); + expect(document.body.style.cursor).toBe(''); + fireEvent.mouseDown(splitSeparator, {pageX: 304, pageY: 20, button: 0}); + fireEvent.mouseMove(splitSeparator, {pageX: 307, pageY: 20}); // extra move so cursor change flushes + fireEvent.mouseMove(splitSeparator, {pageX: 310, pageY: 20}); + fireEvent.mouseUp(splitSeparator, {pageX: 310, pageY: 20, button: 0}); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + }); + + // V2 version doesn't have this capability, firstly limited by onMouseDown `if (this.props.primarySize !== undefined) {` + it.each` + Name | Component | props + ${'SplitView'} | ${SplitView} | ${{primarySize: 500, UNSAFE_className: 'splitview'}} + `('$Name can have its size controlled', async function ({Component, props}) { + let onResizeSpy = jest.fn(); + let {getByRole} = render( + +
Left
+
Right
+
+ ); + let splitview = document.querySelector('.splitview'); + splitview.getBoundingClientRect = jest.fn(() => ({left: 0, right: 1000})); + let splitSeparator = getByRole('separator'); + let primaryPane = splitview.childNodes[0]; + expect(primaryPane).toHaveAttribute('style', `width: ${props.primarySize}px;`); + let id = primaryPane.getAttribute('id'); + expect(splitSeparator).toHaveAttribute('aria-controls', id); + expect(splitSeparator).toHaveAttribute('tabindex', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '50'); + expect(splitSeparator).toHaveAttribute('aria-valuemin', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuemax', '100'); - it.each` - Name | Component | props - ${'SplitView'} | ${SplitView} | ${{allowsResizing: false, UNSAFE_className: 'splitview'}} - ${'V2SplitView'} | ${V2SplitView} | ${{resizable: false, className: 'splitview'}} - `('$Name can be non-resizable', async function ({Component, props}) { - let {getByRole} = render( - -
Left
-
Right
-
- ); - - let splitview = document.querySelector('.splitview'); - splitview.getBoundingClientRect = jest.fn(() => ({left: 0, right: 1000})); - let splitSeparator = getByRole('separator'); - let primaryPane = splitview.childNodes[0]; - expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); - let id = primaryPane.getAttribute('id'); - expect(splitSeparator).toHaveAttribute('aria-controls', id); - expect(splitSeparator).not.toHaveAttribute('tabindex'); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); - expect(splitSeparator).toHaveAttribute('aria-valuemin', '0'); - expect(splitSeparator).toHaveAttribute('aria-valuemax', '100'); - - // move mouse over to 310 and verify that the size changed - fireEvent.mouseEnter(splitSeparator, {clientX: 304, clientY: 20}); - fireEvent.mouseMove(splitSeparator, {clientX: 304, clientY: 20}); - expect(document.body.style.cursor).toBe(''); - fireEvent.mouseDown(splitSeparator, {clientX: 304, clientY: 20, button: 0}); - fireEvent.mouseMove(splitSeparator, {clientX: 307, clientY: 20}); // extra move so cursor change flushes - fireEvent.mouseMove(splitSeparator, {clientX: 310, clientY: 20}); - fireEvent.mouseUp(splitSeparator, {clientX: 310, clientY: 20, button: 0}); - expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + // move mouse over to 505 and verify that the size didn't change + fireEvent.mouseEnter(splitSeparator, {pageX: props.primarySize, pageY: 20}); + fireEvent.mouseMove(splitSeparator, {pageX: props.primarySize, pageY: 20}); + expect(document.body.style.cursor).toBe('ew-resize'); + fireEvent.mouseDown(splitSeparator, {pageX: props.primarySize, pageY: 20, button: 0}); + fireEvent.mouseMove(splitSeparator, {pageX: props.primarySize + 5, pageY: 20}); + fireEvent.mouseUp(splitSeparator, {pageX: props.primarySize + 5, pageY: 20, button: 0}); + expect(primaryPane).toHaveAttribute('style', `width: ${props.primarySize}px;`); + expect(onResizeSpy).toHaveBeenCalledWith(props.primarySize + 5); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '50'); + }); }); - // V2 version doesn't have this capability, firstly limited by onMouseDown `if (this.props.primarySize !== undefined) {` - it.each` - Name | Component | props - ${'SplitView'} | ${SplitView} | ${{primarySize: 500, UNSAFE_className: 'splitview'}} - `('$Name can have its size controlled', async function ({Component, props}) { - let onResizeSpy = jest.fn(); - let {getByRole} = render( - -
Left
-
Right
-
- ); - - let splitview = document.querySelector('.splitview'); - splitview.getBoundingClientRect = jest.fn(() => ({left: 0, right: 1000})); - let splitSeparator = getByRole('separator'); - let primaryPane = splitview.childNodes[0]; - expect(primaryPane).toHaveAttribute('style', `width: ${props.primarySize}px;`); - let id = primaryPane.getAttribute('id'); - expect(splitSeparator).toHaveAttribute('aria-controls', id); - expect(splitSeparator).toHaveAttribute('tabindex', '0'); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '50'); - expect(splitSeparator).toHaveAttribute('aria-valuemin', '0'); - expect(splitSeparator).toHaveAttribute('aria-valuemax', '100'); - - // move mouse over to 505 and verify that the size didn't change - fireEvent.mouseEnter(splitSeparator, {clientX: props.primarySize, clientY: 20}); - fireEvent.mouseMove(splitSeparator, {clientX: props.primarySize, clientY: 20}); - expect(document.body.style.cursor).toBe('ew-resize'); - fireEvent.mouseDown(splitSeparator, {clientX: props.primarySize, clientY: 20, button: 0}); - fireEvent.mouseMove(splitSeparator, {clientX: props.primarySize + 5, clientY: 20}); - fireEvent.mouseUp(splitSeparator, {clientX: props.primarySize + 5, clientY: 20, button: 0}); - expect(primaryPane).toHaveAttribute('style', `width: ${props.primarySize}px;`); - expect(onResizeSpy).toHaveBeenCalledWith(props.primarySize + 5); - expect(splitSeparator).toHaveAttribute('aria-valuenow', '50'); + describe('using PointerEvent', function () { + // Stub offsetWidth/offsetHeight so we can calculate min/max sizes correctly + let stub1, stub2; + beforeAll(function () { + stub1 = jest.spyOn(window.HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(() => 1000); + stub2 = jest.spyOn(window.HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(() => 1000); + }); + + afterAll(function () { + stub1.mockReset(); + stub2.mockReset(); + }); + + afterEach(function () { + document.body.style.cursor = null; + }); + + installPointerEvent(); + + it.each` + Name | Component | props + ${'SplitView'} | ${SplitView} | ${{UNSAFE_className: 'splitview'}} + `('$Name handles defaults', async function ({Component, props}) { + let onResizeSpy = jest.fn(); + let onResizeEndSpy = jest.fn(); + let {getByRole} = render( + +
Left
+
Right
+
+ ); + + let splitview = document.querySelector('.splitview'); + splitview.getBoundingClientRect = jest.fn(() => ({left: 0, right: 1000})); + let splitSeparator = getByRole('separator'); + let primaryPane = splitview.childNodes[0]; + expect(splitview.childNodes[1]).toEqual(splitSeparator); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + let id = primaryPane.getAttribute('id'); + expect(splitSeparator).toHaveAttribute('aria-controls', id); + expect(splitSeparator).toHaveAttribute('tabindex', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuemin', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuemax', '100'); + expect(onResizeSpy).not.toHaveBeenCalled(); + expect(onResizeEndSpy).not.toHaveBeenCalled(); + + let pageY = 20; // arbitrary + // move pointer over to 310 and verify that the size changed to 310 + fireEvent.pointerEnter(splitSeparator, {pageX: 304, pageY}); + fireEvent.pointerMove(splitSeparator, {pageX: 304, pageY}); + expect(document.body.style.cursor).toBe('e-resize'); + fireEvent.pointerDown(splitSeparator, {pageX: 304, pageY, button: 0}); + fireEvent.pointerMove(splitSeparator, {pageX: 307, pageY}); // extra move so cursor change flushes + expect(onResizeSpy).toHaveBeenLastCalledWith(307); + fireEvent.pointerMove(splitSeparator, {pageX: 310, pageY}); + expect(onResizeSpy).toHaveBeenLastCalledWith(310); + expect(document.body.style.cursor).toBe('ew-resize'); + fireEvent.pointerUp(splitSeparator, {pageX: 310, pageY, button: 0}); + expect(onResizeEndSpy).toHaveBeenLastCalledWith(310); + expect(primaryPane).toHaveAttribute('style', 'width: 310px;'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '1'); + + // move pointer to the far end so that it maxes out 1000px - secondaryMin(304px) = 696px + // visual state: primary is maxed out, secondary is at minimum, pointer is beyond the container width + fireEvent.pointerEnter(splitSeparator, {pageX: 310, pageY}); + fireEvent.pointerMove(splitSeparator, {pageX: 310, pageY}); + expect(document.body.style.cursor).toBe('ew-resize'); + fireEvent.pointerDown(splitSeparator, {pageX: 310, pageY, button: 0}); + fireEvent.pointerMove(splitSeparator, {pageX: 1001, pageY}); + expect(onResizeSpy).toHaveBeenLastCalledWith(696); + fireEvent.pointerUp(splitSeparator, {pageX: 1001, pageY, button: 0}); + expect(onResizeEndSpy).toHaveBeenLastCalledWith(696); + expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); + + // move pointer so we shrink to the far left for minimum, non-collapisble = 304px; + // visual state: primary is at minimum size, secondary is maxed out, pointer is to the left of the split by a lot + fireEvent.pointerEnter(splitSeparator, {pageX: 696, pageY}); + fireEvent.pointerMove(splitSeparator, {pageX: 696, pageY}); + expect(document.body.style.cursor).toBe('w-resize'); + fireEvent.pointerDown(splitSeparator, {pageX: 696, pageY, button: 0}); + fireEvent.pointerMove(splitSeparator, {pageX: 0, pageY}); + expect(onResizeSpy).toHaveBeenLastCalledWith(304); + fireEvent.pointerUp(splitSeparator, {pageX: 0, pageY, button: 0}); + expect(onResizeEndSpy).toHaveBeenLastCalledWith(304); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate right 304px + 10px = 314px + fireEvent.keyDown(splitSeparator, {key: 'Right'}); + expect(onResizeSpy).toHaveBeenLastCalledWith(314); + expect(onResizeEndSpy).toHaveBeenLastCalledWith(314); + expect(primaryPane).toHaveAttribute('style', 'width: 314px;'); + fireEvent.keyUp(splitSeparator, {key: 'Right'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '2'); + + // use keyboard to navigate left 314px - 10px = 304px + fireEvent.keyDown(splitSeparator, {key: 'Left'}); + expect(onResizeSpy).toHaveBeenLastCalledWith(304); + expect(onResizeEndSpy).toHaveBeenLastCalledWith(304); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + fireEvent.keyUp(splitSeparator, {key: 'Left'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate left a second time should do nothing + let countResizeEndCall = onResizeEndSpy.mock.calls.length; + fireEvent.keyDown(splitSeparator, {key: 'Left'}); + expect(onResizeEndSpy.mock.calls.length).toBe(countResizeEndCall); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + fireEvent.keyUp(splitSeparator, {key: 'Left'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate up shouldn't move + fireEvent.keyDown(splitSeparator, {key: 'Up'}); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + fireEvent.keyUp(splitSeparator, {key: 'Up'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate down shouldn't move + fireEvent.keyDown(splitSeparator, {key: 'Down'}); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + fireEvent.keyUp(splitSeparator, {key: 'Down'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate End should maximize the primary view -> 696px + fireEvent.keyDown(splitSeparator, {key: 'End'}); + expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); + fireEvent.keyUp(splitSeparator, {key: 'End'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); + + // use keyboard to navigate right at max size should do nothing + fireEvent.keyDown(splitSeparator, {key: 'Right'}); + expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); + fireEvent.keyUp(splitSeparator, {key: 'Right'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); + + // use keyboard to navigate Home should minimize the primary view -> 304px + fireEvent.keyDown(splitSeparator, {key: 'Home'}); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + fireEvent.keyUp(splitSeparator, {key: 'Home'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate Enter should do nothing because default does not allow collapsings + fireEvent.keyDown(splitSeparator, {key: 'Enter'}); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + fireEvent.keyUp(splitSeparator, {key: 'Enter'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate right 304px + 10px = 314px and then use Enter, should do nothing + fireEvent.keyDown(splitSeparator, {key: 'Right'}); + expect(primaryPane).toHaveAttribute('style', 'width: 314px;'); + fireEvent.keyUp(splitSeparator, {key: 'Right'}); + fireEvent.keyDown(splitSeparator, {key: 'Enter'}); + expect(primaryPane).toHaveAttribute('style', 'width: 314px;'); + fireEvent.keyUp(splitSeparator, {key: 'Enter'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '2'); + }); + + it.each` + Name | Component | props + ${'SplitView'} | ${SplitView} | ${{UNSAFE_className: 'splitview'}} + `('$Name handles primaryPane being second', function ({Component, props}) { + let {getByRole} = render( + +
Left
+
Right
+
+ ); + + let splitview = document.querySelector('.splitview'); + splitview.getBoundingClientRect = jest.fn(() => ({left: 0, right: 1000})); + let splitSeparator = getByRole('separator'); + let primaryPane = splitview.childNodes[2]; + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + let id = primaryPane.getAttribute('id'); + expect(splitSeparator).toHaveAttribute('aria-controls', id); + expect(splitSeparator).toHaveAttribute('tabindex', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuemin', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuemax', '100'); + expect(document.body.style.cursor).toBe(''); + + // primary as second means pointer needs to go to 696px to get the handle + // move pointer over to 670 and verify that the size changed to 1000px - 670px = 330px + fireEvent.pointerEnter(splitSeparator, {pageX: 696, pageY: 20}); + fireEvent.pointerMove(splitSeparator, {pageX: 696, pageY: 20}); + expect(document.body.style.cursor).toBe('w-resize'); + fireEvent.pointerDown(splitSeparator, {pageX: 696, pageY: 20, button: 0}); + fireEvent.pointerMove(splitSeparator, {pageX: 680, pageY: 20}); + fireEvent.pointerMove(splitSeparator, {pageX: 670, pageY: 20}); + fireEvent.pointerUp(splitSeparator, {pageX: 670, pageY: 20, button: 0}); + expect(primaryPane).toHaveAttribute('style', 'width: 330px;'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '6'); + + // move pointer to the far end so that it maxes out 1000px - secondaryMin(304px) = 696px + fireEvent.pointerEnter(splitSeparator, {pageX: 670, pageY: 20}); + fireEvent.pointerMove(splitSeparator, {pageX: 670, pageY: 20}); + expect(document.body.style.cursor).toBe('ew-resize'); + fireEvent.pointerDown(splitSeparator, {pageX: 670, pageY: 20, button: 0}); + fireEvent.pointerMove(splitSeparator, {pageX: 0, pageY: 20}); + fireEvent.pointerUp(splitSeparator, {pageX: 0, pageY: 20, button: 0}); + expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); + + // use keyboard to navigate right 696px - 10px = 686px + fireEvent.keyDown(splitSeparator, {key: 'Right'}); + expect(primaryPane).toHaveAttribute('style', 'width: 686px;'); + fireEvent.keyUp(splitSeparator, {key: 'Right'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '97'); + + // use keyboard to navigate left 686px + 10px = 696px + fireEvent.keyDown(splitSeparator, {key: 'Left'}); + expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); + fireEvent.keyUp(splitSeparator, {key: 'Left'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); + + // use keyboard to navigate Home should minimize the primary view -> 304px + fireEvent.keyDown(splitSeparator, {key: 'Home'}); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + fireEvent.keyUp(splitSeparator, {key: 'Home'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate right at min size should do nothing + fireEvent.keyDown(splitSeparator, {key: 'Right'}); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + fireEvent.keyUp(splitSeparator, {key: 'Right'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate End should maximize the primary view -> 696px + fireEvent.keyDown(splitSeparator, {key: 'End'}); + expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); + fireEvent.keyUp(splitSeparator, {key: 'End'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); + + // use keyboard to navigate left at max size should do nothing + fireEvent.keyDown(splitSeparator, {key: 'Left'}); + expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); + fireEvent.keyUp(splitSeparator, {key: 'Left'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); + }); + + + it.each` + Name | Component | props + ${'SplitView'} | ${SplitView} | ${{allowsCollapsing: true, UNSAFE_className: 'splitview'}} + `('$Name handles allowsCollapsing', function ({Component, props}) { + let {getByRole} = render( + +
Left
+
Right
+
+ ); + + let splitview = document.querySelector('.splitview'); + splitview.getBoundingClientRect = jest.fn(() => ({left: 0, right: 1000})); + let splitSeparator = getByRole('separator'); + let primaryPane = splitview.childNodes[0]; + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + let id = primaryPane.getAttribute('id'); + expect(splitSeparator).toHaveAttribute('aria-controls', id); + expect(splitSeparator).toHaveAttribute('tabindex', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuemin', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuemax', '100'); + expect(document.body.style.cursor).toBe(''); + + // move pointer over to 310 and verify that the size changed + fireEvent.pointerEnter(splitSeparator, {pageX: 304, pageY: 20}); + fireEvent.pointerMove(splitSeparator, {pageX: 304, pageY: 20}); + expect(document.body.style.cursor).toBe('e-resize'); + fireEvent.pointerDown(splitSeparator, {pageX: 304, pageY: 20, button: 0}); + fireEvent.pointerMove(splitSeparator, {pageX: 310, pageY: 20}); + fireEvent.pointerUp(splitSeparator, {pageX: 310, pageY: 20, button: 0}); + expect(primaryPane).toHaveAttribute('style', 'width: 310px;'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '1'); + + // move pointer to the far end so that it maxes out 1000px - secondaryMin(304px) = 696px + fireEvent.pointerEnter(splitSeparator, {pageX: 310, pageY: 20}); + fireEvent.pointerMove(splitSeparator, {pageX: 310, pageY: 20}); + expect(document.body.style.cursor).toBe('ew-resize'); + fireEvent.pointerDown(splitSeparator, {pageX: 310, pageY: 20, button: 0}); + fireEvent.pointerMove(splitSeparator, {pageX: 1001, pageY: 20}); + fireEvent.pointerUp(splitSeparator, {pageX: 1001, pageY: 20, button: 0}); + expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); + + // move pointer so we shrink to the collapse point 304px - 50px threshold - 1px = 253px + fireEvent.pointerEnter(splitSeparator, {pageX: 696, pageY: 20}); + fireEvent.pointerMove(splitSeparator, {pageX: 696, pageY: 20}); + expect(document.body.style.cursor).toBe('w-resize'); + fireEvent.pointerDown(splitSeparator, {pageX: 696, pageY: 20, button: 0}); + fireEvent.pointerMove(splitSeparator, {pageX: 253, pageY: 20}); + fireEvent.pointerUp(splitSeparator, {pageX: 253, pageY: 20, button: 0}); + expect(primaryPane).toHaveAttribute('style', 'width: 0px;'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '-77'); + + // move pointer so we recover from the collapsing + fireEvent.pointerEnter(splitSeparator, {pageX: 0, pageY: 20}); + fireEvent.pointerMove(splitSeparator, {pageX: 0, pageY: 20}); + expect(document.body.style.cursor).toBe('e-resize'); + fireEvent.pointerDown(splitSeparator, {pageX: 0, pageY: 20, button: 0}); + fireEvent.pointerMove(splitSeparator, {pageX: 254, pageY: 20}); + fireEvent.pointerUp(splitSeparator, {pageX: 254, pageY: 20, button: 0}); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate right 304px + 10px = 314px + fireEvent.keyDown(splitSeparator, {key: 'Right'}); + expect(primaryPane).toHaveAttribute('style', 'width: 314px;'); + fireEvent.keyUp(splitSeparator, {key: 'Right'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '2'); + + // use keyboard to navigate left 314px - 10px = 304px + fireEvent.keyDown(splitSeparator, {key: 'Left'}); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + fireEvent.keyUp(splitSeparator, {key: 'Left'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate left a second time should do nothing + fireEvent.keyDown(splitSeparator, {key: 'Left'}); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + fireEvent.keyUp(splitSeparator, {key: 'Left'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate up shouldn't move + fireEvent.keyDown(splitSeparator, {key: 'Up'}); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + fireEvent.keyUp(splitSeparator, {key: 'Up'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate down shouldn't move + fireEvent.keyDown(splitSeparator, {key: 'Down'}); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + fireEvent.keyUp(splitSeparator, {key: 'Down'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + + // use keyboard to navigate End should maximize the primary view -> 696px + fireEvent.keyDown(splitSeparator, {key: 'End'}); + expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); + fireEvent.keyUp(splitSeparator, {key: 'End'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); + + // use keyboard to navigate right at max should do nothing + fireEvent.keyDown(splitSeparator, {key: 'Right'}); + expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); + fireEvent.keyUp(splitSeparator, {key: 'Right'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); + + // use keyboard to navigate Home should minimize the primary view, b/c of allows collapsing -> 0px + fireEvent.keyDown(splitSeparator, {key: 'Home'}); + expect(primaryPane).toHaveAttribute('style', 'width: 0px;'); + fireEvent.keyUp(splitSeparator, {key: 'Home'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '-77'); + + // reset us to max size -> 696px + fireEvent.keyDown(splitSeparator, {key: 'End'}); + expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); + fireEvent.keyUp(splitSeparator, {key: 'End'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); + + // collapse us with Enter + fireEvent.keyDown(splitSeparator, {key: 'Enter'}); + expect(primaryPane).toHaveAttribute('style', 'width: 0px;'); + fireEvent.keyUp(splitSeparator, {key: 'Enter'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '-77'); + + // use keyboard to navigate Right should restore it to the last size + fireEvent.keyDown(splitSeparator, {key: 'Enter'}); + expect(primaryPane).toHaveAttribute('style', 'width: 696px;'); + fireEvent.keyUp(splitSeparator, {key: 'Enter'}); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); + }); + + it.each` + Name | Component | props + ${'SplitView'} | ${SplitView} | ${{UNSAFE_className: 'splitview'}} + `('$Name should render a vertical split view', function ({Component, props}) { + let {getByRole} = render( + +
Left
+
Right
+
+ ); + + let splitview = document.querySelector('.splitview'); + splitview.getBoundingClientRect = jest.fn(() => ({top: 0, bottom: 1000})); + let splitSeparator = getByRole('separator'); + let primaryPane = splitview.childNodes[0]; + expect(primaryPane).toHaveAttribute('style', 'height: 304px;'); + let id = primaryPane.getAttribute('id'); + expect(splitSeparator).toHaveAttribute('aria-controls', id); + expect(splitSeparator).toHaveAttribute('tabindex', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuemin', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuemax', '100'); + + // move pointer over to 310 and verify that the size changed + fireEvent.pointerEnter(splitSeparator, {pageX: 20, pageY: 304}); + fireEvent.pointerMove(splitSeparator, {pageX: 20, pageY: 304}); + expect(document.body.style.cursor).toBe('s-resize'); + fireEvent.pointerDown(splitSeparator, {pageX: 20, pageY: 304, button: 0}); + fireEvent.pointerMove(splitSeparator, {pageX: 20, pageY: 307}); // extra move so cursor change flushes + fireEvent.pointerMove(splitSeparator, {pageX: 20, pageY: 310}); + expect(document.body.style.cursor).toBe('ns-resize'); + fireEvent.pointerUp(splitSeparator, {pageX: 20, pageY: 310, button: 0}); + expect(primaryPane).toHaveAttribute('style', 'height: 310px;'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '1'); + + // move pointer to the far end so that it maxes out 1000px - secondaryMin(304px) = 696px + fireEvent.pointerEnter(splitSeparator, {pageX: 20, pageY: 310}); + fireEvent.pointerMove(splitSeparator, {pageX: 20, pageY: 310}); + expect(document.body.style.cursor).toBe('ns-resize'); + fireEvent.pointerDown(splitSeparator, {pageX: 20, pageY: 310, button: 0}); + fireEvent.pointerMove(splitSeparator, {pageX: 20, pageY: 1001}); + fireEvent.pointerUp(splitSeparator, {pageX: 20, pageY: 1001, button: 0}); + expect(primaryPane).toHaveAttribute('style', 'height: 696px;'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '100'); + + // move pointer so we shrink to the far left for minimum, non-collapisble = 304px; + fireEvent.pointerEnter(splitSeparator, {pageX: 20, pageY: 696}); + fireEvent.pointerMove(splitSeparator, {pageX: 20, pageY: 696}); + expect(document.body.style.cursor).toBe('n-resize'); + fireEvent.pointerDown(splitSeparator, {pageX: 20, pageY: 696, button: 0}); + fireEvent.pointerMove(splitSeparator, {pageX: 20, pageY: 0}); + fireEvent.pointerUp(splitSeparator, {pageX: 20, pageY: 0, button: 0}); + expect(primaryPane).toHaveAttribute('style', 'height: 304px;'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + }); + + + it.each` + Name | Component | props + ${'SplitView'} | ${SplitView} | ${{allowsResizing: false, UNSAFE_className: 'splitview'}} + `('$Name can be non-resizable', async function ({Component, props}) { + let {getByRole} = render( + +
Left
+
Right
+
+ ); + + let splitview = document.querySelector('.splitview'); + splitview.getBoundingClientRect = jest.fn(() => ({left: 0, right: 1000})); + let splitSeparator = getByRole('separator'); + let primaryPane = splitview.childNodes[0]; + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + let id = primaryPane.getAttribute('id'); + expect(splitSeparator).toHaveAttribute('aria-controls', id); + expect(splitSeparator).not.toHaveAttribute('tabindex'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuemin', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuemax', '100'); + + // move pointer over to 310 and verify that the size changed + fireEvent.pointerEnter(splitSeparator, {pageX: 304, pageY: 20}); + fireEvent.pointerMove(splitSeparator, {pageX: 304, pageY: 20}); + expect(document.body.style.cursor).toBe(''); + fireEvent.pointerDown(splitSeparator, {pageX: 304, pageY: 20, button: 0}); + fireEvent.pointerMove(splitSeparator, {pageX: 307, pageY: 20}); // extra move so cursor change flushes + fireEvent.pointerMove(splitSeparator, {pageX: 310, pageY: 20}); + fireEvent.pointerUp(splitSeparator, {pageX: 310, pageY: 20, button: 0}); + expect(primaryPane).toHaveAttribute('style', 'width: 304px;'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '0'); + }); + + // V2 version doesn't have this capability, firstly limited by onpointerDown `if (this.props.primarySize !== undefined) {` + it.each` + Name | Component | props + ${'SplitView'} | ${SplitView} | ${{primarySize: 500, UNSAFE_className: 'splitview'}} + `('$Name can have its size controlled', async function ({Component, props}) { + let onResizeSpy = jest.fn(); + let {getByRole} = render( + +
Left
+
Right
+
+ ); + + let splitview = document.querySelector('.splitview'); + splitview.getBoundingClientRect = jest.fn(() => ({left: 0, right: 1000})); + let splitSeparator = getByRole('separator'); + let primaryPane = splitview.childNodes[0]; + expect(primaryPane).toHaveAttribute('style', `width: ${props.primarySize}px;`); + let id = primaryPane.getAttribute('id'); + expect(splitSeparator).toHaveAttribute('aria-controls', id); + expect(splitSeparator).toHaveAttribute('tabindex', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '50'); + expect(splitSeparator).toHaveAttribute('aria-valuemin', '0'); + expect(splitSeparator).toHaveAttribute('aria-valuemax', '100'); + + // move pointer over to 505 and verify that the size didn't change + fireEvent.pointerEnter(splitSeparator, {pageX: props.primarySize, pageY: 20}); + fireEvent.pointerMove(splitSeparator, {pageX: props.primarySize, pageY: 20}); + expect(document.body.style.cursor).toBe('ew-resize'); + fireEvent.pointerDown(splitSeparator, {pageX: props.primarySize, pageY: 20, button: 0}); + fireEvent.pointerMove(splitSeparator, {pageX: props.primarySize + 5, pageY: 20}); + fireEvent.pointerUp(splitSeparator, {pageX: props.primarySize + 5, pageY: 20, button: 0}); + expect(primaryPane).toHaveAttribute('style', `width: ${props.primarySize}px;`); + expect(onResizeSpy).toHaveBeenCalledWith(props.primarySize + 5); + expect(splitSeparator).toHaveAttribute('aria-valuenow', '50'); + }); }); }); diff --git a/packages/@react-spectrum/test-utils/src/events.ts b/packages/@react-spectrum/test-utils/src/events.ts index 86be6aa51e8..e167e85b1e4 100644 --- a/packages/@react-spectrum/test-utils/src/events.ts +++ b/packages/@react-spectrum/test-utils/src/events.ts @@ -28,6 +28,61 @@ export function triggerPress(element, opts = {}) { }); } +/** + * Enables reading pageX/pageY from fireEvent.mouse*(..., {pageX: ..., pageY: ...}). + */ +export function installMouseEvent() { + beforeAll(() => { + let oldMouseEvent = MouseEvent; + // @ts-ignore + global.MouseEvent = class FakeMouseEvent extends MouseEvent { + _init: {pageX: number, pageY: number}; + constructor(name, init) { + super(name, init); + this._init = init; + } + get pageX() { + return this._init.pageX; + } + get pageY() { + return this._init.pageY; + } + }; + // @ts-ignore + global.MouseEvent.oldMouseEvent = oldMouseEvent; + }); + afterAll(() => { + // @ts-ignore + global.MouseEvent = global.MouseEvent.oldMouseEvent; + }); +} + +export function installPointerEvent() { + beforeAll(() => { + // @ts-ignore + global.PointerEvent = class FakePointerEvent extends MouseEvent { + _init: {pageX: number, pageY: number, pointerType: string}; + constructor(name, init) { + super(name, init); + this._init = init; + } + get pointerType() { + return this._init.pointerType; + } + get pageX() { + return this._init.pageX; + } + get pageY() { + return this._init.pageY; + } + }; + }); + afterAll(() => { + // @ts-ignore + delete global.PointerEvent; + }); +} + /** * Must **not** be called inside an `act` callback! * diff --git a/packages/@react-stately/slider/src/useSliderState.ts b/packages/@react-stately/slider/src/useSliderState.ts index deef23e5510..60894d438c0 100644 --- a/packages/@react-stately/slider/src/useSliderState.ts +++ b/packages/@react-stately/slider/src/useSliderState.ts @@ -64,7 +64,7 @@ export const DEFAULT_MAX_VALUE = 100; export const DEFAULT_STEP_VALUE = 1; export function useSliderState(props: SliderProps): SliderState { - let {isDisabled, minValue = DEFAULT_MIN_VALUE, maxValue = DEFAULT_MAX_VALUE, formatOptions, step = DEFAULT_STEP_VALUE} = props; + const {isDisabled, minValue = DEFAULT_MIN_VALUE, maxValue = DEFAULT_MAX_VALUE, formatOptions, step = DEFAULT_STEP_VALUE} = props; const [values, setValues] = useControlledState( props.value as any, @@ -75,6 +75,11 @@ export function useSliderState(props: SliderProps): SliderState { const isEditablesRef = useRef(new Array(values.length).fill(true)); const [focusedIndex, setFocusedIndex] = useState(undefined); + const valuesRef = useRef(null); + valuesRef.current = values; + const isDraggingsRef = useRef(null); + isDraggingsRef.current = isDraggings; + const formatter = useNumberFormatter(formatOptions); function getValuePercent(value: number) { @@ -105,7 +110,8 @@ export function useSliderState(props: SliderProps): SliderState { // Round value to multiple of step, clamp value between min and max value = clamp(getRoundedValue(value), thisMin, thisMax); - setValues(values => replaceIndex(values, index, value)); + valuesRef.current = replaceIndex(valuesRef.current, index, value); + setValues(valuesRef.current); } function updateDragging(index: number, dragging: boolean) { @@ -113,12 +119,13 @@ export function useSliderState(props: SliderProps): SliderState { return; } - const newDraggings = replaceIndex(isDraggings, index, dragging); - setDraggings(newDraggings); + const wasDragging = isDraggingsRef.current[index]; + isDraggingsRef.current = replaceIndex(isDraggingsRef.current, index, dragging); + setDraggings(isDraggingsRef.current); // Call onChangeEnd if no handles are dragging. - if (props.onChangeEnd && isDraggings[index] && !newDraggings.some(Boolean)) { - props.onChangeEnd(values); + if (props.onChangeEnd && wasDragging && !isDraggingsRef.current.some(Boolean)) { + props.onChangeEnd(valuesRef.current); } } diff --git a/packages/@react-stately/splitview/src/useSplitViewState.ts b/packages/@react-stately/splitview/src/useSplitViewState.ts index d187aa30348..2ced881bdf6 100644 --- a/packages/@react-stately/splitview/src/useSplitViewState.ts +++ b/packages/@react-stately/splitview/src/useSplitViewState.ts @@ -17,7 +17,7 @@ import {useRef, useState} from 'react'; const COLLAPSE_THRESHOLD = 50; export function useSplitViewState(props: SplitViewStatelyProps): SplitViewState { - let { + const { defaultPrimarySize = 304, primarySize, allowsCollapsing = false, @@ -25,21 +25,27 @@ export function useSplitViewState(props: SplitViewStatelyProps): SplitViewState onResizeEnd } = props; - let [minPos, setMinPos] = useState(0); - let [maxPos, setMaxPos] = useState(0); - let [dragging, setDragging] = useState(false); - let realTimeDragging = useRef(false); - let [hovered, setHovered] = useState(false); - let [offset, setOffset] = useControlledState(primarySize, defaultPrimarySize, () => {}); - let prevOffset = useRef(offset); + const [minPos, setMinPos] = useState(0); + const [maxPos, setMaxPos] = useState(0); + const [dragging, setDragging] = useState(false); + const realTimeDragging = useRef(false); + const [hovered, setHovered] = useState(false); + const [offset, setOffset] = useControlledState(primarySize, defaultPrimarySize, () => {}); + const realTimeOffset = useRef(null); + realTimeOffset.current = offset; + const prevOffset = useRef(offset); + + const hasChangedSinceDragStart = useRef(null); let callOnResize = (value) => { if (onResize && value !== offset) { + hasChangedSinceDragStart.current = true; onResize(value); } }; + let callOnResizeEnd = (value) => { - if (onResizeEnd) { + if (hasChangedSinceDragStart.current && onResizeEnd) { onResizeEnd(value); } }; @@ -59,45 +65,50 @@ export function useSplitViewState(props: SplitViewStatelyProps): SplitViewState let setOffsetValue = (value) => { let nextOffset = boundOffset(value); callOnResize(nextOffset); - if (!realTimeDragging.current) { - callOnResizeEnd(nextOffset); - } setOffset(nextOffset); + realTimeOffset.current = nextOffset; }; let setDraggingValue = (value) => { - realTimeDragging.current = value; - setDragging(value); + if (realTimeDragging.current !== value) { + realTimeDragging.current = value; + setDragging(value); + if (!value) { + callOnResizeEnd(realTimeOffset.current); + } else { + hasChangedSinceDragStart.current = false; + } + } }; let setHoverValue = (value) => { setHovered(value); }; - let increment = () => setOffset(prevHandleOffset => { - let nextOffset = boundOffset(prevHandleOffset + 10); + let increment = () => { + let nextOffset = boundOffset(realTimeOffset.current + 10); if (nextOffset !== offset) { callOnResize(nextOffset); - callOnResizeEnd(nextOffset); } - return nextOffset; - }); + realTimeOffset.current = nextOffset; + setOffset(nextOffset); + }; - let decrement = () => setOffset(prevHandleOffset => { - let nextOffset = boundOffset(prevHandleOffset - 10); + let decrement = () => { + let nextOffset = boundOffset(realTimeOffset.current - 10); if (nextOffset !== offset) { callOnResize(nextOffset); - callOnResizeEnd(nextOffset); } - return nextOffset; - }); + realTimeOffset.current = nextOffset; + setOffset(nextOffset); + }; let decrementToMin = () => { let nextOffset = allowsCollapsing ? 0 : minPos; if (nextOffset !== offset) { callOnResize(nextOffset); - callOnResizeEnd(nextOffset); setOffset(nextOffset); + realTimeOffset.current = nextOffset; } }; @@ -105,8 +116,8 @@ export function useSplitViewState(props: SplitViewStatelyProps): SplitViewState let nextOffset = maxPos; if (nextOffset !== offset) { callOnResize(nextOffset); - callOnResizeEnd(nextOffset); setOffset(nextOffset); + realTimeOffset.current = nextOffset; } }; @@ -120,7 +131,6 @@ export function useSplitViewState(props: SplitViewStatelyProps): SplitViewState } let nextOffset = prevHandleOffset === 0 ? oldOffset || minPos : 0; callOnResize(nextOffset); - callOnResizeEnd(nextOffset); return nextOffset; }); diff --git a/packages/@react-types/shared/src/splitview.d.ts b/packages/@react-types/shared/src/splitview.d.ts index e6febd13cb2..f4f2e8dac98 100644 --- a/packages/@react-types/shared/src/splitview.d.ts +++ b/packages/@react-types/shared/src/splitview.d.ts @@ -12,7 +12,6 @@ import { Dispatch, - MouseEventHandler, MutableRefObject, ReactElement, SetStateAction @@ -55,7 +54,6 @@ export interface SplitViewState { export interface SplitViewAriaProps { id?: string, - onMouseDown?: MouseEventHandler, allowsResizing?: boolean, orientation?: Orientation, primaryPane?: 0 | 1, diff --git a/yarn.lock b/yarn.lock index 05a34541f7d..b4b0c579cc0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1381,7 +1381,7 @@ core-js-pure "^3.0.0" regenerator-runtime "^0.13.4" -"@babel/runtime@7.9.0", "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.2", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.4", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": +"@babel/runtime@7.9.0", "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.11.2", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.2", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.4", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": version "7.9.0" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.9.0.tgz#337eda67401f5b066a6f205a3113d4ac18ba495b" integrity sha512-cTIudHnzuWLS56ik4DnRnqqNf8MkdUzV4iFFI1h7Jo9xvrpQROYaAnaSd2mHLQAzzZAPfATynX5ord6YlNYNMA== @@ -4236,14 +4236,16 @@ "@svgr/plugin-svgo" "^4.3.1" loader-utils "^1.2.3" -"@testing-library/dom@^7.22.3", "@testing-library/dom@^7.23.0": - version "7.23.0" - resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.23.0.tgz#c54c0fa53705ad867bcefb52fc0c96487fbc10f6" - integrity sha512-H5m090auYH+obdZmsaYLrSWC5OauWD2CvNbz88KBxQJoXgkJzbU0DpAG8BS7Evj5WqCC3nAAKrLS6vw0ljUYLg== +"@testing-library/dom@^7.23.0", "@testing-library/dom@^7.24.2": + version "7.24.3" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.24.3.tgz#dae3071463cf28dc7755b43d9cf2202e34cbb85d" + integrity sha512-6eW9fUhEbR423FZvoHRwbWm9RUUByLWGayYFNVvqTnQLYvsNpBS4uEuKH9aqr3trhxFwGVneJUonehL3B1sHJw== dependencies: + "@babel/code-frame" "^7.10.4" "@babel/runtime" "^7.10.3" "@types/aria-query" "^4.2.0" aria-query "^4.2.2" + chalk "^4.1.0" dom-accessibility-api "^0.5.1" pretty-format "^26.4.2" @@ -4269,13 +4271,13 @@ "@babel/runtime" "^7.5.4" "@types/testing-library__react-hooks" "^3.3.0" -"@testing-library/react@^10.4.9": - version "10.4.9" - resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-10.4.9.tgz#9faa29c6a1a217bf8bbb96a28bd29d7a847ca150" - integrity sha512-pHZKkqUy0tmiD81afs8xfiuseXfU/N7rAX3iKjeZYje86t9VaB0LrxYVa+OOsvkrveX5jCK3IjajVn2MbePvqA== +"@testing-library/react@^11.0.4": + version "11.0.4" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-11.0.4.tgz#c84082bfe1593d8fcd475d46baee024452f31dee" + integrity sha512-U0fZO2zxm7M0CB5h1+lh31lbAwMSmDMEMGpMT3BUPJwIjDEKYWOV4dx7lb3x2Ue0Pyt77gmz/VropuJnSz/Iew== dependencies: - "@babel/runtime" "^7.10.3" - "@testing-library/dom" "^7.22.3" + "@babel/runtime" "^7.11.2" + "@testing-library/dom" "^7.24.2" "@testing-library/user-event@^12.1.3": version "12.1.3" @@ -6994,7 +6996,7 @@ chalk@^3.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" -chalk@^4.0.0: +chalk@^4.0.0, chalk@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" integrity sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==