diff --git a/packages/@adobe/spectrum-css-temp/components/splitview/index.css b/packages/@adobe/spectrum-css-temp/components/splitview/index.css index dfdac1a9b9d..b55bdbf345d 100644 --- a/packages/@adobe/spectrum-css-temp/components/splitview/index.css +++ b/packages/@adobe/spectrum-css-temp/components/splitview/index.css @@ -43,6 +43,7 @@ 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 { @@ -51,6 +52,7 @@ 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%; diff --git a/packages/@react-aria/slider/src/useSlider.ts b/packages/@react-aria/slider/src/useSlider.ts index 19ed02ddec8..2d6b8cb19f1 100644 --- a/packages/@react-aria/slider/src/useSlider.ts +++ b/packages/@react-aria/slider/src/useSlider.ts @@ -56,7 +56,7 @@ export function useSlider( // It is set onMouseDown; see trackProps below. const realTimeTrackDraggingIndex = useRef(undefined); const isTrackDragging = useRef(false); - const {onMouseDown, onMouseEnter, onMouseOut} = useDrag1D({ + const draggableProps = useDrag1D({ containerRef: trackRef as any, reverse: false, orientation: 'horizontal', @@ -82,6 +82,7 @@ export function useSlider( } }); + let downHandlerName = draggableProps.onMouseDown ? 'onMouseDown' : 'onPointerDown'; return { labelProps, @@ -93,7 +94,7 @@ export function useSlider( ...fieldProps }, trackProps: mergeProps({ - onMouseDown: (e: React.MouseEvent) => { + [ downHandlerName ]: (e: { clientX: number, clientY: number, preventDefault: () => void }) => { // We only trigger track-dragging if the user clicks on the track itself. if (trackRef.current && isSliderEditable) { // Find the closest thumb @@ -118,7 +119,7 @@ export function useSlider( // 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. + // if no dragging actually occurs. state.setThumbDragging(realTimeTrackDraggingIndex.current, true); state.setThumbValue(index, value); } else { @@ -127,7 +128,7 @@ export function useSlider( } } }, { - onMouseDown, onMouseEnter, onMouseOut + ...draggableProps }) }; } diff --git a/packages/@react-aria/slider/src/useSliderThumb.ts b/packages/@react-aria/slider/src/useSliderThumb.ts index 0c54d1ee7f6..aa381b48e66 100644 --- a/packages/@react-aria/slider/src/useSliderThumb.ts +++ b/packages/@react-aria/slider/src/useSliderThumb.ts @@ -113,11 +113,7 @@ export function useSliderThumb( state.setThumbValue(index, parseFloat(e.target.value)); } }), - thumbProps: isEditable ? mergeProps({ - onMouseDown: draggableProps.onMouseDown, - onMouseEnter: draggableProps.onMouseEnter, - onMouseOut: draggableProps.onMouseOut - }, { + thumbProps: isEditable ? mergeProps(draggableProps, { onMouseDown: focusInput }) : {}, labelProps diff --git a/packages/@react-aria/slider/stories/story-slider.css b/packages/@react-aria/slider/stories/story-slider.css index aeb91d61cbd..189da7f21c1 100644 --- a/packages/@react-aria/slider/stories/story-slider.css +++ b/packages/@react-aria/slider/stories/story-slider.css @@ -27,6 +27,7 @@ display: flex; flex-direction: column; align-items: center; + touch-action: none; } .thumbHandle { diff --git a/packages/@react-aria/slider/test/PointerEventFake.js b/packages/@react-aria/slider/test/PointerEventFake.js new file mode 100644 index 00000000000..d80a4eae24c --- /dev/null +++ b/packages/@react-aria/slider/test/PointerEventFake.js @@ -0,0 +1,17 @@ +/* + This exists because JSDOM's PointerEvent doesn't work right. + see: https://github.com/jsdom/jsdom/pull/2666. + and https://github.com/testing-library/dom-testing-library/issues/558. +*/ + +const pointerEventCtorProps = ['clientX', 'clientY', 'pointerType']; +export default class PointerEventFake extends Event { + constructor(type, props = {}) { + super(type, props); + pointerEventCtorProps.forEach((prop) => { + if (props[prop] != null) { + this[prop] = props[prop]; + } + }); + } +} diff --git a/packages/@react-aria/slider/test/useSlider.test.js b/packages/@react-aria/slider/test/useSlider.test.js index 2fb66c8b78c..78b4ac1c284 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 PointerEventFake from './PointerEventFake.js'; import * as React from 'react'; import {renderHook} from '@testing-library/react-hooks'; import {useRef} from 'react'; @@ -131,4 +132,104 @@ describe('useSlider', () => { expect(stateRef.current.values).toEqual([10, 80]); }); }); + + describe('interactions on track using pointerEvents', () => { + let widthStub; + let origPointerEvent; + beforeAll(() => { + origPointerEvent = window.PointerEvent; + window.PointerEvent = PointerEventFake; + widthStub = jest.spyOn(window.HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(() => 100); + }); + afterAll(() => { + delete window.PointerEvent; + if (origPointerEvent) { + window.PointerEvent = origPointerEvent; + } + widthStub.mockReset(); + }); + + 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.pointerDown(track, {clientX: 20}); + fireEvent.pointerUp(track, {clientX: 20}); + + expect(onChangeSpy).toHaveBeenLastCalledWith([20, 80]); + expect(onChangeEndSpy).toHaveBeenLastCalledWith([20, 80]); + expect(stateRef.current.values).toEqual([20, 80]); + + track = screen.getByTestId('track'); + fireEvent.pointerDown(track, {clientX: 90}); + fireEvent.pointerUp(track, {clientX: 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.pointerDown(track, {clientX: 20}); + expect(onChangeSpy).toHaveBeenLastCalledWith([20, 80]); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([20, 80]); + + fireEvent.pointerMove(track, {clientX: 30}); + expect(onChangeSpy).toHaveBeenLastCalledWith([30, 80]); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([30, 80]); + + fireEvent.pointerMove(track, {clientX: 40}); + expect(onChangeSpy).toHaveBeenLastCalledWith([40, 80]); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([40, 80]); + + fireEvent.pointerUp(track, {clientX: 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.pointerDown(track, {clientX: 20}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([10, 80]); + + fireEvent.pointerMove(track, {clientX: 30}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([10, 80]); + + fireEvent.pointerUp(track, {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..25f087c62a7 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 PointerEventFake from './PointerEventFake'; import * as React from 'react'; import {renderHook} from '@testing-library/react-hooks'; import {useRef} from 'react'; @@ -138,60 +139,128 @@ describe('useSliderThumb', () => {
); } + describe('using PointerEvents', () => { + let origPointerEvent; + beforeAll(() => { + origPointerEvent = window.PointerEvent; + window.PointerEvent = PointerEventFake; + }); + afterAll(() => { + delete window.PointerEvent; + if (origPointerEvent) { + window.PointerEvent = origPointerEvent; + } + }); + it('can be moved by dragging', () => { + let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); + render(); - 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]); + // Drag thumb0 + let thumb0 = screen.getByTestId('thumb0'); + fireEvent.pointerDown(thumb0, {clientX: 10}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([10, 80]); + + fireEvent.pointerMove(thumb0, {clientX: 20}); + expect(onChangeSpy).toHaveBeenLastCalledWith([20, 80]); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([20, 80]); + + fireEvent.pointerMove(thumb0, {clientX: 30}); + expect(onChangeSpy).toHaveBeenLastCalledWith([30, 80]); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([30, 80]); + + fireEvent.pointerMove(thumb0, {clientX: 40}); + fireEvent.pointerUp(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.pointerDown(thumb1, {clientX: 80}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([40, 80]); + + fireEvent.pointerMove(thumb1, {clientX: 60}); + expect(onChangeSpy).toHaveBeenLastCalledWith([40, 60]); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([40, 60]); + + fireEvent.pointerMove(thumb1, {clientX: 30}); + expect(onChangeSpy).toHaveBeenLastCalledWith([40, 40]); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([40, 40]); + + fireEvent.pointerUp(thumb1, {clientX: 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}); + 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('interactions on thumbs, where track contains thumbs', () => { @@ -226,29 +295,69 @@ 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', () => { + let origPointerEvent; + beforeAll(() => { + origPointerEvent = window.PointerEvent; + window.PointerEvent = PointerEventFake; + }); + afterAll(() => { + delete window.PointerEvent; + if (origPointerEvent) { + window.PointerEvent = origPointerEvent; + } + }); + 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}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([10]); + + fireEvent.pointerMove(thumb0, {clientX: 20}); + expect(onChangeSpy).toHaveBeenLastCalledWith([20]); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([20]); + + fireEvent.pointerMove(thumb0, {clientX: 40}); + fireEvent.pointerUp(thumb0, {clientX: 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}); + 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); + }); }); }); }); diff --git a/packages/@react-aria/splitview/src/useSplitView.ts b/packages/@react-aria/splitview/src/useSplitView.ts index eb290ababac..f363aade344 100644 --- a/packages/@react-aria/splitview/src/useSplitView.ts +++ b/packages/@react-aria/splitview/src/useSplitView.ts @@ -10,10 +10,9 @@ * governing permissions and limitations under the License. */ -import {chain} from '@react-aria/utils'; import {HTMLAttributes, useEffect, useRef} from 'react'; +import {mergeProps, useDrag1D} from '@react-aria/utils'; import {SplitViewAriaProps, SplitViewState} from '@react-types/shared'; -import {useDrag1D} from '@react-aria/utils'; import {useId} from '@react-aria/utils'; interface AriaSplitViewProps { @@ -80,11 +79,12 @@ export function useSplitView(props: SplitViewAriaProps, state: SplitViewState): 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 mergedDraggableProps = {}; + if (allowsResizing) { + const downHandlerName = draggableProps.onMouseDown ? 'onMouseDown' : 'onPointerDown'; + mergedDraggableProps = mergeProps(draggableProps, {[downHandlerName]: propsOnMouseDown}); + } return { containerProps: { @@ -99,10 +99,7 @@ export function useSplitView(props: SplitViewAriaProps, state: SplitViewState): 'aria-labelledby': props['aria-labelledby'], role: 'separator', 'aria-controls': id, - onMouseDown, - onMouseEnter, - onMouseOut, - onKeyDown + ...mergedDraggableProps }, primaryPaneProps: { id diff --git a/packages/@react-aria/utils/src/useDrag1D.ts b/packages/@react-aria/utils/src/useDrag1D.ts index 720488f63f8..b62c5d89c8d 100644 --- a/packages/@react-aria/utils/src/useDrag1D.ts +++ b/packages/@react-aria/utils/src/useDrag1D.ts @@ -40,8 +40,8 @@ const draggingElements: HTMLElement[] = []; export function useDrag1D(props: UseDrag1DProps): HTMLAttributes { let {containerRef, reverse, orientation, onHover, onDrag, onPositionChange, onIncrement, onDecrement, onIncrementToMax, onDecrementToMin, onCollapseToggle} = props; - let getPosition = (e) => orientation === 'horizontal' ? e.clientX : e.clientY; - let getNextOffset = (e: MouseEvent) => { + let getPosition = (e: { clientX: number, clientY: number }) => orientation === 'horizontal' ? e.clientX : e.clientY; + let getNextOffset = (e: { clientX: number, clientY: number }) => { let containerOffset = getOffset(containerRef.current, reverse, orientation); let mouseOffset = getPosition(e); let nextOffset = reverse ? containerOffset - mouseOffset : mouseOffset - containerOffset; @@ -183,5 +183,65 @@ export function useDrag1D(props: UseDrag1DProps): HTMLAttributes { } }; + let onPointerEnter = () => { + if (onHover) { + onHover(true); + } + }; + + let onPointerOut = () => { + if (onHover) { + onHover(false); + } + }; + + let onPointerMove = (e: React.PointerEvent) => { + e.preventDefault(); + if (!dragging.current) { + return; + } + let nextOffset = getNextOffset(e); + + if (prevPosition.current === nextOffset) { + return; + } + prevPosition.current = nextOffset; + if (onPositionChange) { + onPositionChange(nextOffset); + } + }; + + let onPointerUp = (e: React.PointerEvent) => { + e.stopPropagation(); + e.preventDefault(); + if (!dragging.current) { + return; + } + + dragging.current = false; + let nextOffset = getNextOffset(e); + if (onDrag) { + onDrag(false); + } + if (onPositionChange) { + onPositionChange(nextOffset); + } + }; + + let onPointerDown = (e: React.PointerEvent) => { + dragging.current = true; + // @ts-ignore + if (e.nativeEvent?.target?.setPointerCapture) { + // @ts-ignore + e.nativeEvent.target.setPointerCapture(e.pointerId); + } + if (onDrag) { + onDrag(true); + } + }; + + if ('PointerEvent' in window) { + return {onKeyDown, onPointerDown, onPointerUp, onPointerMove, onPointerEnter, onPointerOut}; + } return {onMouseDown, onMouseEnter, onMouseOut, onKeyDown}; } diff --git a/packages/@react-spectrum/splitview/test/SplitView.test.js b/packages/@react-spectrum/splitview/test/SplitView.test.js index 0354bc3ca13..40a3ea7811d 100644 --- a/packages/@react-spectrum/splitview/test/SplitView.test.js +++ b/packages/@react-spectrum/splitview/test/SplitView.test.js @@ -15,6 +15,20 @@ import React from 'react'; import {SplitView} from '../'; import V2SplitView from '@react/react-spectrum/SplitView'; +// workaround for: https://github.com/testing-library/react-testing-library/issues/783 +const origPointerEnter = fireEvent.pointerEnter; +const origPointerLeave = fireEvent.pointerLeave; +fireEvent.pointerEnter = (...args) => { + fireEvent.pointerOver(...args); + origPointerEnter(...args); +}; + +fireEvent.pointerLeave = (...args) => { + fireEvent.pointerOver(...args); + origPointerLeave(...args); +}; + + describe('SplitView tests', function () { // Stub offsetWidth/offsetHeight so we can calculate min/max sizes correctly let stub1, stub2; @@ -521,3 +535,531 @@ describe('SplitView tests', function () { expect(splitSeparator).toHaveAttribute('aria-valuenow', '50'); }); }); + +describe('SplitView tests using PointerEvents', function () { + + // PointerEvents are not natively supported by JSDOM currently, you have to bring your own Event Implementation. + const pointerEventCtorProps = ['clientX', 'clientY', 'pointerType']; + class PointerEventFake extends Event { + constructor(type, props = {}) { + super(type, props); + pointerEventCtorProps.forEach((prop) => { + if (props[prop] != null) { + this[prop] = props[prop]; + } + }); + } + } + let origPointerEvent; + // Stub offsetWidth/offsetHeight so we can calculate min/max sizes correctly + let stub1, stub2; + beforeAll(function () { + origPointerEvent = window.PointerEvent; + window.PointerEvent = PointerEventFake; + + stub1 = jest.spyOn(window.HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(() => 1000); + stub2 = jest.spyOn(window.HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(() => 1000); + }); + + afterAll(function () { + delete window.PointerEvent; + if (origPointerEvent) { + window.PointerEvent = origPointerEvent; + } + stub1.mockReset(); + stub2.mockReset(); + }); + + afterEach(function () { + document.body.style.cursor = null; + }); + + 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 pointer over to 310 and verify that the size changed to 310 + fireEvent.pointerEnter(splitSeparator, {clientX: 304, clientY}); + fireEvent.pointerMove(splitSeparator, {clientX: 304, clientY}); + expect(document.body.style.cursor).toBe('e-resize'); + fireEvent.pointerDown(splitSeparator, {clientX: 304, clientY, button: 0}); + fireEvent.pointerMove(splitSeparator, {clientX: 307, clientY}); // extra move so cursor change flushes + expect(onResizeSpy).toHaveBeenLastCalledWith(307); + fireEvent.pointerMove(splitSeparator, {clientX: 310, clientY}); + expect(onResizeSpy).toHaveBeenLastCalledWith(310); + expect(document.body.style.cursor).toBe('ew-resize'); + fireEvent.pointerUp(splitSeparator, {clientX: 310, clientY, 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, {clientX: 310, clientY}); + fireEvent.pointerMove(splitSeparator, {clientX: 310, clientY}); + expect(document.body.style.cursor).toBe('ew-resize'); + fireEvent.pointerDown(splitSeparator, {clientX: 310, clientY, button: 0}); + fireEvent.pointerMove(splitSeparator, {clientX: 1001, clientY}); + expect(onResizeSpy).toHaveBeenLastCalledWith(696); + fireEvent.pointerUp(splitSeparator, {clientX: 1001, clientY, 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, {clientX: 696, clientY}); + fireEvent.pointerMove(splitSeparator, {clientX: 696, clientY}); + expect(document.body.style.cursor).toBe('w-resize'); + fireEvent.pointerDown(splitSeparator, {clientX: 696, clientY, button: 0}); + fireEvent.pointerMove(splitSeparator, {clientX: 0, clientY}); + expect(onResizeSpy).toHaveBeenLastCalledWith(304); + fireEvent.pointerUp(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'); + }); + + 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 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, {clientX: 696, clientY: 20}); + fireEvent.pointerMove(splitSeparator, {clientX: 696, clientY: 20}); + expect(document.body.style.cursor).toBe('w-resize'); + fireEvent.pointerDown(splitSeparator, {clientX: 696, clientY: 20, button: 0}); + fireEvent.pointerMove(splitSeparator, {clientX: 680, clientY: 20}); + fireEvent.pointerMove(splitSeparator, {clientX: 670, clientY: 20}); + fireEvent.pointerUp(splitSeparator, {clientX: 670, clientY: 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, {clientX: 670, clientY: 20}); + fireEvent.pointerMove(splitSeparator, {clientX: 670, clientY: 20}); + expect(document.body.style.cursor).toBe('ew-resize'); + fireEvent.pointerDown(splitSeparator, {clientX: 670, clientY: 20, button: 0}); + fireEvent.pointerMove(splitSeparator, {clientX: 0, clientY: 20}); + fireEvent.pointerUp(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'); + }); + + + 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 pointer over to 310 and verify that the size changed + fireEvent.pointerEnter(splitSeparator, {clientX: 304, clientY: 20}); + fireEvent.pointerMove(splitSeparator, {clientX: 304, clientY: 20}); + expect(document.body.style.cursor).toBe('e-resize'); + fireEvent.pointerDown(splitSeparator, {clientX: 304, clientY: 20, button: 0}); + fireEvent.pointerMove(splitSeparator, {clientX: 310, clientY: 20}); + fireEvent.pointerUp(splitSeparator, {clientX: 310, clientY: 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, {clientX: 310, clientY: 20}); + fireEvent.pointerMove(splitSeparator, {clientX: 310, clientY: 20}); + expect(document.body.style.cursor).toBe('ew-resize'); + fireEvent.pointerDown(splitSeparator, {clientX: 310, clientY: 20, button: 0}); + fireEvent.pointerMove(splitSeparator, {clientX: 1001, clientY: 20}); + fireEvent.pointerUp(splitSeparator, {clientX: 1001, clientY: 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, {clientX: 696, clientY: 20}); + fireEvent.pointerMove(splitSeparator, {clientX: 696, clientY: 20}); + expect(document.body.style.cursor).toBe('w-resize'); + fireEvent.pointerDown(splitSeparator, {clientX: 696, clientY: 20, button: 0}); + fireEvent.pointerMove(splitSeparator, {clientX: 253, clientY: 20}); + fireEvent.pointerUp(splitSeparator, {clientX: 253, clientY: 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, {clientX: 0, clientY: 20}); + fireEvent.pointerMove(splitSeparator, {clientX: 0, clientY: 20}); + expect(document.body.style.cursor).toBe('e-resize'); + fireEvent.pointerDown(splitSeparator, {clientX: 0, clientY: 20, button: 0}); + fireEvent.pointerMove(splitSeparator, {clientX: 254, clientY: 20}); + fireEvent.pointerUp(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'}} + ${'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 pointer over to 310 and verify that the size changed + fireEvent.pointerEnter(splitSeparator, {clientX: 20, clientY: 304}); + fireEvent.pointerMove(splitSeparator, {clientX: 20, clientY: 304}); + expect(document.body.style.cursor).toBe('s-resize'); + fireEvent.pointerDown(splitSeparator, {clientX: 20, clientY: 304, button: 0}); + fireEvent.pointerMove(splitSeparator, {clientX: 20, clientY: 307}); // extra move so cursor change flushes + fireEvent.pointerMove(splitSeparator, {clientX: 20, clientY: 310}); + expect(document.body.style.cursor).toBe('ns-resize'); + fireEvent.pointerUp(splitSeparator, {clientX: 20, clientY: 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, {clientX: 20, clientY: 310}); + fireEvent.pointerMove(splitSeparator, {clientX: 20, clientY: 310}); + expect(document.body.style.cursor).toBe('ns-resize'); + fireEvent.pointerDown(splitSeparator, {clientX: 20, clientY: 310, button: 0}); + fireEvent.pointerMove(splitSeparator, {clientX: 20, clientY: 1001}); + fireEvent.pointerUp(splitSeparator, {clientX: 20, clientY: 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, {clientX: 20, clientY: 696}); + fireEvent.pointerMove(splitSeparator, {clientX: 20, clientY: 696}); + expect(document.body.style.cursor).toBe('n-resize'); + fireEvent.pointerDown(splitSeparator, {clientX: 20, clientY: 696, button: 0}); + fireEvent.pointerMove(splitSeparator, {clientX: 20, clientY: 0}); + fireEvent.pointerUp(splitSeparator, {clientX: 20, clientY: 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'}} + ${'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 pointer over to 310 and verify that the size changed + fireEvent.pointerEnter(splitSeparator, {clientX: 304, clientY: 20}); + fireEvent.pointerMove(splitSeparator, {clientX: 304, clientY: 20}); + expect(document.body.style.cursor).toBe(''); + fireEvent.pointerDown(splitSeparator, {clientX: 304, clientY: 20, button: 0}); + fireEvent.pointerMove(splitSeparator, {clientX: 307, clientY: 20}); // extra move so cursor change flushes + fireEvent.pointerMove(splitSeparator, {clientX: 310, clientY: 20}); + fireEvent.pointerUp(splitSeparator, {clientX: 310, clientY: 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, {clientX: props.primarySize, clientY: 20}); + fireEvent.pointerMove(splitSeparator, {clientX: props.primarySize, clientY: 20}); + expect(document.body.style.cursor).toBe('ew-resize'); + fireEvent.pointerDown(splitSeparator, {clientX: props.primarySize, clientY: 20, button: 0}); + fireEvent.pointerMove(splitSeparator, {clientX: props.primarySize + 5, clientY: 20}); + fireEvent.pointerUp(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'); + }); +});