diff --git a/packages/@react-aria/color/src/useColorArea.ts b/packages/@react-aria/color/src/useColorArea.ts index 886b3b81fe8..9dcb821ec21 100644 --- a/packages/@react-aria/color/src/useColorArea.ts +++ b/packages/@react-aria/color/src/useColorArea.ts @@ -68,44 +68,34 @@ export function useColorArea(props: AriaColorAreaProps, state: ColorAreaState, i let {keyboardProps} = useKeyboard({ onKeyDown(e) { // these are the cases that useMove doesn't handle - if (!((e.shiftKey && /^Arrow(?:Right|Left|Up|Down)$/.test(e.key)) || /^(PageUp|PageDown|Home|End)$/.test(e.key))) { + if (!/^(PageUp|PageDown|Home|End)$/.test(e.key)) { + e.continuePropagation(); return; } + // same handling as useMove, don't need to stop propagation, useKeyboard will do that for us + e.preventDefault(); + // remember to set this and unset it so that onChangeEnd is fired stateRef.current.setDragging(true); - let isPage = e.shiftKey; switch (e.key) { case 'PageUp': - isPage = true; - case 'ArrowUp': - case 'Up': - stateRef.current.incrementY(isPage); + stateRef.current.incrementY(stateRef.current.yChannelPageStep); focusedInputRef.current = inputYRef.current; break; case 'PageDown': - isPage = true; - case 'ArrowDown': - case 'Down': - stateRef.current.decrementY(isPage); + stateRef.current.decrementY(stateRef.current.yChannelPageStep); focusedInputRef.current = inputYRef.current; break; case 'Home': - isPage = true; - case 'ArrowLeft': - case 'Left': - direction === 'rtl' ? stateRef.current.incrementX(isPage) : stateRef.current.decrementX(isPage); + direction === 'rtl' ? stateRef.current.incrementX(stateRef.current.xChannelPageStep) : stateRef.current.decrementX(stateRef.current.xChannelPageStep); focusedInputRef.current = inputXRef.current; break; case 'End': - isPage = true; - case 'ArrowRight': - case 'Right': - direction === 'rtl' ? stateRef.current.decrementX(isPage) : stateRef.current.incrementX(isPage); + direction === 'rtl' ? stateRef.current.decrementX(stateRef.current.xChannelPageStep) : stateRef.current.incrementX(stateRef.current.xChannelPageStep); focusedInputRef.current = inputXRef.current; break; } stateRef.current.setDragging(false); if (focusedInputRef.current) { - e.preventDefault(); focusInput(focusedInputRef.current ? focusedInputRef : inputXRef); focusedInputRef.current = undefined; } @@ -117,24 +107,26 @@ export function useColorArea(props: AriaColorAreaProps, state: ColorAreaState, i currentPosition.current = null; stateRef.current.setDragging(true); }, - onMove({deltaX, deltaY, pointerType}) { + onMove({deltaX, deltaY, pointerType, shiftKey}) { if (currentPosition.current == null) { currentPosition.current = stateRef.current.getThumbPosition(); } let {width, height} = containerRef.current.getBoundingClientRect(); if (pointerType === 'keyboard') { - if (deltaX > 0 || deltaX < 0) { - stateRef.current[`${deltaX > 0 ? 'increment' : 'decrement'}X`](); - } - if (deltaY > 0 || deltaY < 0) { - stateRef.current[`${deltaY < 0 ? 'increment' : 'decrement'}Y`](); + if (deltaX > 0) { + stateRef.current.incrementX(shiftKey ? stateRef.current.xChannelPageStep : stateRef.current.xChannelStep); + } else if (deltaX < 0) { + stateRef.current.decrementX(shiftKey ? stateRef.current.xChannelPageStep : stateRef.current.xChannelStep); + } else if (deltaY > 0) { + stateRef.current.decrementY(shiftKey ? stateRef.current.yChannelPageStep : stateRef.current.yChannelStep); + } else if (deltaY < 0) { + stateRef.current.incrementY(shiftKey ? stateRef.current.yChannelPageStep : stateRef.current.yChannelStep); } // set the focused input based on which axis has the greater delta focusedInputRef.current = (deltaX !== 0 || deltaY !== 0) && Math.abs(deltaY) > Math.abs(deltaX) ? inputYRef.current : inputXRef.current; - } - currentPosition.current.x += (direction === 'rtl' ? -1 : 1) * deltaX / width ; - currentPosition.current.y += deltaY / height; - if (pointerType !== 'keyboard') { + } else { + currentPosition.current.x += (direction === 'rtl' ? -1 : 1) * deltaX / width ; + currentPosition.current.y += deltaY / height; stateRef.current.setColorFromPoint(currentPosition.current.x, currentPosition.current.y); } }, @@ -279,8 +271,6 @@ export function useColorArea(props: AriaColorAreaProps, state: ColorAreaState, i } }) }, keyboardProps, movePropsThumb); - // order matters, keyboard need to finish before move so that onChangeEnd is fired last - // after valueRef in stately has been updated let xInputLabellingProps = useLabels({ diff --git a/packages/@react-aria/color/src/useColorWheel.ts b/packages/@react-aria/color/src/useColorWheel.ts index ac49ee4d21a..8ea75aa7523 100644 --- a/packages/@react-aria/color/src/useColorWheel.ts +++ b/packages/@react-aria/color/src/useColorWheel.ts @@ -33,8 +33,6 @@ interface ColorWheelAria { inputProps: InputHTMLAttributes } -const PAGE_MIN_STEP_SIZE = 6; - /** * Provides the behavior and accessibility implementation for a color wheel component. * Color wheels allow users to adjust the hue of an HSL or HSB color value on a circular track. @@ -62,12 +60,38 @@ export function useColorWheel(props: ColorWheelAriaProps, state: ColorWheelState stateRef.current = state; let currentPosition = useRef<{x: number, y: number}>(null); + + let {keyboardProps} = useKeyboard({ + onKeyDown(e) { + // these are the cases that useMove doesn't handle + if (!/^(PageUp|PageDown)$/.test(e.key)) { + e.continuePropagation(); + return; + } + // same handling as useMove, don't need to stop propagation, useKeyboard will do that for us + e.preventDefault(); + // remember to set this and unset it so that onChangeEnd is fired + stateRef.current.setDragging(true); + switch (e.key) { + case 'PageUp': + e.preventDefault(); + state.increment(stateRef.current.pageStep); + break; + case 'PageDown': + e.preventDefault(); + state.decrement(stateRef.current.pageStep); + break; + } + stateRef.current.setDragging(false); + } + }); + let moveHandler = { onMoveStart() { currentPosition.current = null; state.setDragging(true); }, - onMove({deltaX, deltaY, pointerType}) { + onMove({deltaX, deltaY, pointerType, shiftKey}) { if (currentPosition.current == null) { currentPosition.current = stateRef.current.getThumbPosition(thumbRadius); } @@ -75,9 +99,9 @@ export function useColorWheel(props: ColorWheelAriaProps, state: ColorWheelState currentPosition.current.y += deltaY; if (pointerType === 'keyboard') { if (deltaX > 0 || deltaY < 0) { - state.increment(); + state.increment(shiftKey ? stateRef.current.pageStep : stateRef.current.step); } else if (deltaX < 0 || deltaY > 0) { - state.decrement(); + state.decrement(shiftKey ? stateRef.current.pageStep : stateRef.current.step); } } else { stateRef.current.setHueFromPoint(currentPosition.current.x, currentPosition.current.y, thumbRadius); @@ -169,21 +193,6 @@ export function useColorWheel(props: ColorWheelAriaProps, state: ColorWheelState } }; - let {keyboardProps} = useKeyboard({ - onKeyDown(e) { - switch (e.key) { - case 'PageUp': - e.preventDefault(); - state.increment(PAGE_MIN_STEP_SIZE); - break; - case 'PageDown': - e.preventDefault(); - state.decrement(PAGE_MIN_STEP_SIZE); - break; - } - } - }); - let trackInteractions = isDisabled ? {} : mergeProps({ onMouseDown: (e: React.MouseEvent) => { if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey) { @@ -218,7 +227,7 @@ export function useColorWheel(props: ColorWheelAriaProps, state: ColorWheelState onTouchStart: (e: React.TouchEvent) => { onThumbDown(e.changedTouches[0].identifier); } - }, movePropsThumb, keyboardProps); + }, keyboardProps, movePropsThumb); let {x, y} = state.getThumbPosition(thumbRadius); // Provide a default aria-label if none is given diff --git a/packages/@react-aria/color/test/useColorWheel.test.tsx b/packages/@react-aria/color/test/useColorWheel.test.tsx index 3dc198621fc..13e94abf7a8 100644 --- a/packages/@react-aria/color/test/useColorWheel.test.tsx +++ b/packages/@react-aria/color/test/useColorWheel.test.tsx @@ -52,24 +52,18 @@ function ColorWheel(props: ColorWheelProps) { describe('useColorWheel', () => { let onChangeSpy = jest.fn(); - afterEach(() => { - onChangeSpy.mockClear(); - }); - beforeAll(() => { // @ts-ignore - jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => cb()); - jest.useFakeTimers(); + jest.useFakeTimers('modern'); }); afterAll(() => { jest.useRealTimers(); - // @ts-ignore - window.requestAnimationFrame.mockRestore(); }); afterEach(() => { // for restoreTextSelection jest.runAllTimers(); + onChangeSpy.mockClear(); }); it('sets input props', () => { diff --git a/packages/@react-aria/interactions/src/useMove.ts b/packages/@react-aria/interactions/src/useMove.ts index bf12333ecd1..e514eb75979 100644 --- a/packages/@react-aria/interactions/src/useMove.ts +++ b/packages/@react-aria/interactions/src/useMove.ts @@ -20,6 +20,13 @@ interface MoveResult { moveProps: HTMLAttributes } +interface EventBase { + shiftKey: boolean, + ctrlKey: boolean, + metaKey: boolean, + altKey: boolean +} + /** * Handles move interactions across mouse, touch, and keyboard, including dragging with * the mouse or touch, and using the arrow keys. Normalizes behavior across browsers and @@ -43,7 +50,7 @@ export function useMove(props: MoveEvents): MoveResult { disableTextSelection(); state.current.didMove = false; }; - let move = (pointerType: PointerType, deltaX: number, deltaY: number) => { + let move = (originalEvent: EventBase, pointerType: PointerType, deltaX: number, deltaY: number) => { if (deltaX === 0 && deltaY === 0) { return; } @@ -52,22 +59,34 @@ export function useMove(props: MoveEvents): MoveResult { state.current.didMove = true; onMoveStart?.({ type: 'movestart', - pointerType + pointerType, + shiftKey: originalEvent.shiftKey, + metaKey: originalEvent.metaKey, + ctrlKey: originalEvent.ctrlKey, + altKey: originalEvent.altKey }); } onMove({ type: 'move', pointerType, deltaX: deltaX, - deltaY: deltaY + deltaY: deltaY, + shiftKey: originalEvent.shiftKey, + metaKey: originalEvent.metaKey, + ctrlKey: originalEvent.ctrlKey, + altKey: originalEvent.altKey }); }; - let end = (pointerType: PointerType) => { + let end = (originalEvent: EventBase, pointerType: PointerType) => { restoreTextSelection(); if (state.current.didMove) { onMoveEnd?.({ type: 'moveend', - pointerType + pointerType, + shiftKey: originalEvent.shiftKey, + metaKey: originalEvent.metaKey, + ctrlKey: originalEvent.ctrlKey, + altKey: originalEvent.altKey }); } }; @@ -75,13 +94,13 @@ export function useMove(props: MoveEvents): MoveResult { 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); + move(e, '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'); + end(e, 'mouse'); removeGlobalListener(window, 'mousemove', onMouseMove, false); removeGlobalListener(window, 'mouseup', onMouseUp, false); } @@ -98,19 +117,17 @@ export function useMove(props: MoveEvents): MoveResult { }; 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); + move(e, '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'); + end(e, 'touch'); state.current.id = null; removeGlobalListener(window, 'touchmove', onTouchMove); removeGlobalListener(window, 'touchend', onTouchEnd); @@ -135,22 +152,20 @@ export function useMove(props: MoveEvents): MoveResult { } else { let onPointerMove = (e: PointerEvent) => { if (e.pointerId === state.current.id) { - // @ts-ignore - let pointerType: PointerType = e.pointerType || 'mouse'; + let pointerType = (e.pointerType || 'mouse') as PointerType; // 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); + move(e, 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: PointerType = e.pointerType || 'mouse'; - end(pointerType); + let pointerType = (e.pointerType || 'mouse') as PointerType; + end(e, pointerType); state.current.id = null; removeGlobalListener(window, 'pointermove', onPointerMove, false); removeGlobalListener(window, 'pointerup', onPointerUp, false); @@ -172,41 +187,37 @@ export function useMove(props: MoveEvents): MoveResult { }; } - let triggerKeyboardMove = (deltaX: number, deltaY: number) => { + let triggerKeyboardMove = (e: EventBase, deltaX: number, deltaY: number) => { start(); - move('keyboard', deltaX, deltaY); - end('keyboard'); + move(e, 'keyboard', deltaX, deltaY); + end(e, 'keyboard'); }; moveProps.onKeyDown = (e) => { - // don't want useMove to handle shift key + arrow events because it doesn't do anything - if (e.shiftKey) { - return; - } switch (e.key) { case 'Left': case 'ArrowLeft': e.preventDefault(); e.stopPropagation(); - triggerKeyboardMove(-1, 0); + triggerKeyboardMove(e, -1, 0); break; case 'Right': case 'ArrowRight': e.preventDefault(); e.stopPropagation(); - triggerKeyboardMove(1, 0); + triggerKeyboardMove(e, 1, 0); break; case 'Up': case 'ArrowUp': e.preventDefault(); e.stopPropagation(); - triggerKeyboardMove(0, -1); + triggerKeyboardMove(e, 0, -1); break; case 'Down': case 'ArrowDown': e.preventDefault(); e.stopPropagation(); - triggerKeyboardMove(0, 1); + triggerKeyboardMove(e, 0, 1); break; } }; diff --git a/packages/@react-aria/interactions/test/useMove.test.js b/packages/@react-aria/interactions/test/useMove.test.js index b097705aa2a..b2d43ff4ce7 100644 --- a/packages/@react-aria/interactions/test/useMove.test.js +++ b/packages/@react-aria/interactions/test/useMove.test.js @@ -36,6 +36,11 @@ describe('useMove', function () { // for restoreTextSelection jest.runAllTimers(); }); + let altKey = false; + let ctrlKey = false; + let metaKey = false; + let shiftKey = false; + let defaultModifiers = {altKey, ctrlKey, metaKey, shiftKey}; describe('mouse events', function () { installMouseEvent(); @@ -55,9 +60,9 @@ describe('useMove', function () { 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}]); + expect(events).toStrictEqual([{type: 'movestart', pointerType: 'mouse', ...defaultModifiers}, {type: 'move', pointerType: 'mouse', deltaX: 9, deltaY: -5, ...defaultModifiers}]); fireEvent.mouseUp(el); - expect(events).toStrictEqual([{type: 'movestart', pointerType: 'mouse'}, {type: 'move', pointerType: 'mouse', deltaX: 9, deltaY: -5}, {type: 'moveend', pointerType: 'mouse'}]); + expect(events).toStrictEqual([{type: 'movestart', pointerType: 'mouse', ...defaultModifiers}, {type: 'move', pointerType: 'mouse', deltaX: 9, deltaY: -5, ...defaultModifiers}, {type: 'moveend', pointerType: 'mouse', ...defaultModifiers}]); }); it('doesn\'t respond to right click', function () { @@ -114,9 +119,9 @@ describe('useMove', function () { fireEvent.touchStart(el, {changedTouches: [{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}]); + expect(events).toStrictEqual([{type: 'movestart', pointerType: 'touch', ...defaultModifiers}, {type: 'move', pointerType: 'touch', deltaX: 9, deltaY: -5, ...defaultModifiers}]); 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'}]); + expect(events).toStrictEqual([{type: 'movestart', pointerType: 'touch', ...defaultModifiers}, {type: 'move', pointerType: 'touch', deltaX: 9, deltaY: -5, ...defaultModifiers}, {type: 'moveend', pointerType: 'touch', ...defaultModifiers}]); }); it('ends with touchcancel', function () { @@ -134,9 +139,9 @@ describe('useMove', function () { fireEvent.touchStart(el, {changedTouches: [{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}]); + expect(events).toStrictEqual([{type: 'movestart', pointerType: 'touch', ...defaultModifiers}, {type: 'move', pointerType: 'touch', deltaX: 9, deltaY: -5, ...defaultModifiers}]); 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'}]); + expect(events).toStrictEqual([{type: 'movestart', pointerType: 'touch', ...defaultModifiers}, {type: 'move', pointerType: 'touch', deltaX: 9, deltaY: -5, ...defaultModifiers}, {type: 'moveend', pointerType: 'touch', ...defaultModifiers}]); }); it('doesn\'t fire anything when tapping', function () { @@ -174,9 +179,9 @@ describe('useMove', function () { fireEvent.touchEnd(el, {changedTouches: [{identifier: 2, pageX: 10, pageY: 40}]}); 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}]); + expect(events).toStrictEqual([{type: 'movestart', pointerType: 'touch', ...defaultModifiers}, {type: 'move', pointerType: 'touch', deltaX: 9, deltaY: -5, ...defaultModifiers}]); 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'}]); + expect(events).toStrictEqual([{type: 'movestart', pointerType: 'touch', ...defaultModifiers}, {type: 'move', pointerType: 'touch', deltaX: 9, deltaY: -5, ...defaultModifiers}, {type: 'moveend', pointerType: 'touch', ...defaultModifiers}]); }); }); @@ -265,9 +270,9 @@ describe('useMove', function () { fireEvent.touchStart(el, {changedTouches: [{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}]); + expect(eventsChild).toStrictEqual([{type: 'movestart', pointerType: 'touch', ...defaultModifiers}, {type: 'move', pointerType: 'touch', deltaX: 9, deltaY: -5, ...defaultModifiers}]); 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(eventsChild).toStrictEqual([{type: 'movestart', pointerType: 'touch', ...defaultModifiers}, {type: 'move', pointerType: 'touch', deltaX: 9, deltaY: -5, ...defaultModifiers}, {type: 'moveend', pointerType: 'touch', ...defaultModifiers}]); expect(eventsParent).toStrictEqual([]); }); @@ -292,7 +297,7 @@ describe('useMove', function () { 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'}]); + expect(events).toStrictEqual([{type: 'movestart', pointerType: 'keyboard', ...defaultModifiers}, {type: 'move', pointerType: 'keyboard', ...defaultModifiers, ...Result}, {type: 'moveend', pointerType: 'keyboard', ...defaultModifiers}]); }); it('allows handling other key events', function () { @@ -333,9 +338,9 @@ describe('useMove', function () { 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}]); + expect(events).toStrictEqual([{type: 'movestart', pointerType: 'pen', ...defaultModifiers}, {type: 'move', pointerType: 'pen', deltaX: 9, deltaY: -5, ...defaultModifiers}]); 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'}]); + expect(events).toStrictEqual([{type: 'movestart', pointerType: 'pen', ...defaultModifiers}, {type: 'move', pointerType: 'pen', deltaX: 9, deltaY: -5, ...defaultModifiers}, {type: 'moveend', pointerType: 'pen', ...defaultModifiers}]); }); it('doesn\'t respond to right click', function () { @@ -373,9 +378,9 @@ describe('useMove', function () { 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}]); + expect(events).toStrictEqual([{type: 'movestart', pointerType: 'pen', ...defaultModifiers}, {type: 'move', pointerType: 'pen', deltaX: 9, deltaY: -5, ...defaultModifiers}]); 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'}]); + expect(events).toStrictEqual([{type: 'movestart', pointerType: 'pen', ...defaultModifiers}, {type: 'move', pointerType: 'pen', deltaX: 9, deltaY: -5, ...defaultModifiers}, {type: 'moveend', pointerType: 'pen', ...defaultModifiers}]); }); it('doesn\'t fire anything when tapping', function () { @@ -415,9 +420,9 @@ describe('useMove', function () { 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}]); + expect(events).toStrictEqual([{type: 'movestart', pointerType: 'pen', ...defaultModifiers}, {type: 'move', pointerType: 'pen', deltaX: 9, deltaY: -5, ...defaultModifiers}]); 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'}]); + expect(events).toStrictEqual([{type: 'movestart', pointerType: 'pen', ...defaultModifiers}, {type: 'move', pointerType: 'pen', deltaX: 9, deltaY: -5, ...defaultModifiers}, {type: 'moveend', pointerType: 'pen', ...defaultModifiers}]); }); }); }); diff --git a/packages/@react-spectrum/color/test/ColorWheel.test.tsx b/packages/@react-spectrum/color/test/ColorWheel.test.tsx index 48e73b5f553..079653575c6 100644 --- a/packages/@react-spectrum/color/test/ColorWheel.test.tsx +++ b/packages/@react-spectrum/color/test/ColorWheel.test.tsx @@ -33,28 +33,22 @@ describe('ColorWheel', () => { let onChangeSpy = jest.fn(); let onChangeEndSpy = jest.fn(); - afterEach(() => { - onChangeSpy.mockClear(); - onChangeEndSpy.mockClear(); - }); - beforeAll(() => { jest.spyOn(window.HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(() => SIZE); // @ts-ignore - jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => cb()); - jest.useFakeTimers(); + jest.useFakeTimers('modern'); }); afterAll(() => { // @ts-ignore window.HTMLElement.prototype.offsetWidth.mockReset(); jest.useRealTimers(); - // @ts-ignore - window.requestAnimationFrame.mockReset(); }); afterEach(() => { // for restoreTextSelection jest.runAllTimers(); + onChangeSpy.mockClear(); + onChangeEndSpy.mockClear(); }); it('sets input props', () => { @@ -168,16 +162,60 @@ describe('ColorWheel', () => { it('respects step', () => { let defaultColor = parseColor('hsl(0, 100%, 50%)'); - let {getByRole} = render(); + let {getByRole} = render(); let slider = getByRole('slider'); act(() => {slider.focus();}); fireEvent.keyDown(slider, {key: 'Right'}); expect(onChangeSpy).toHaveBeenCalledTimes(1); expect(onChangeSpy.mock.calls[0][0].toString('hsla')).toBe(defaultColor.withChannelValue('hue', 45).toString('hsla')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy.mock.calls[0][0].toString('hsla')).toBe(defaultColor.withChannelValue('hue', 45).toString('hsla')); fireEvent.keyDown(slider, {key: 'Left'}); expect(onChangeSpy).toHaveBeenCalledTimes(2); expect(onChangeSpy.mock.calls[1][0].toString('hsla')).toBe(defaultColor.withChannelValue('hue', 0).toString('hsla')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(2); + expect(onChangeEndSpy.mock.calls[1][0].toString('hsla')).toBe(defaultColor.withChannelValue('hue', 0).toString('hsla')); + }); + + it('respects page steps', () => { + let defaultColor = parseColor('hsl(0, 100%, 50%)'); + let {getByRole} = render(); + let slider = getByRole('slider'); + act(() => {slider.focus();}); + + fireEvent.keyDown(slider, {key: 'PageUp'}); + fireEvent.keyUp(slider, {key: 'PageUp'}); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeSpy.mock.calls[0][0].toString('hsla')).toBe(defaultColor.withChannelValue('hue', 6).toString('hsla')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy.mock.calls[0][0].toString('hsla')).toBe(defaultColor.withChannelValue('hue', 6).toString('hsla')); + fireEvent.keyDown(slider, {key: 'PageDown'}); + fireEvent.keyUp(slider, {key: 'PageDown'}); + expect(onChangeSpy).toHaveBeenCalledTimes(2); + expect(onChangeSpy.mock.calls[1][0].toString('hsla')).toBe(defaultColor.withChannelValue('hue', 0).toString('hsla')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(2); + expect(onChangeEndSpy.mock.calls[1][0].toString('hsla')).toBe(defaultColor.withChannelValue('hue', 0).toString('hsla')); + }); + + it('respects page steps from shift arrow', () => { + let defaultColor = parseColor('hsl(0, 100%, 50%)'); + let {getByRole} = render(); + let slider = getByRole('slider'); + act(() => {slider.focus();}); + + fireEvent.keyDown(slider, {key: 'Right', shiftKey: true}); + fireEvent.keyUp(slider, {key: 'Right', shiftKey: true}); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeSpy.mock.calls[0][0].toString('hsla')).toBe(defaultColor.withChannelValue('hue', 6).toString('hsla')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy.mock.calls[0][0].toString('hsla')).toBe(defaultColor.withChannelValue('hue', 6).toString('hsla')); + fireEvent.keyDown(slider, {key: 'Left', shiftKey: true}); + fireEvent.keyUp(slider, {key: 'Left', shiftKey: true}); + expect(onChangeSpy).toHaveBeenCalledTimes(2); + expect(onChangeSpy.mock.calls[1][0].toString('hsla')).toBe(defaultColor.withChannelValue('hue', 0).toString('hsla')); + expect(onChangeEndSpy).toHaveBeenCalledTimes(2); + expect(onChangeEndSpy.mock.calls[1][0].toString('hsla')).toBe(defaultColor.withChannelValue('hue', 0).toString('hsla')); }); }); diff --git a/packages/@react-stately/color/src/useColorAreaState.ts b/packages/@react-stately/color/src/useColorAreaState.ts index 6c25fbeb29d..974da26db31 100644 --- a/packages/@react-stately/color/src/useColorAreaState.ts +++ b/packages/@react-stately/color/src/useColorAreaState.ts @@ -38,14 +38,14 @@ export interface ColorAreaState { getThumbPosition(): {x: number, y: number}, /** Increments the value of the horizontal axis channel by the channel step or page amount. */ - incrementX(isPageStep?: boolean): void, + incrementX(stepSize?: number): void, /** Decrements the value of the horizontal axis channel by the channel step or page amount. */ - decrementX(isPageStep?: boolean): void, + decrementX(stepSize?: number): void, /** Increments the value of the vertical axis channel by the channel step or page amount. */ - incrementY(isPageStep?: boolean): void, + incrementY(stepSize?: number): void, /** Decrements the value of the vertical axis channel by the channel step or page amount. */ - decrementY(isPageStep?: boolean): void, + decrementY(stepSize?: number): void, /** Whether the color area is currently being dragged. */ readonly isDragging: boolean, @@ -56,6 +56,8 @@ export interface ColorAreaState { channels: {xChannel: ColorChannel, yChannel: ColorChannel, zChannel: ColorChannel}, xChannelStep: number, yChannelStep: number, + xChannelPageStep: number, + yChannelPageStep: number, /** Returns the color that should be displayed in the color area thumb instead of `value`. */ getDisplayColor(): Color @@ -135,11 +137,15 @@ export function useColorAreaState(props: ColorAreaProps): ColorAreaState { valueRef.current = color.withChannelValue(channels.yChannel, v); setColor(valueRef.current); }; + let xChannelPageStep = Math.max(color.getChannelRange(channels.xChannel).pageSize, xChannelStep); + let yChannelPageStep = Math.max(color.getChannelRange(channels.yChannel).pageSize, yChannelStep); return { channels, xChannelStep, yChannelStep, + xChannelPageStep, + yChannelPageStep, value: color, setValue(value) { let c = normalizeColor(value); @@ -177,24 +183,20 @@ export function useColorAreaState(props: ColorAreaProps): ColorAreaState { let y = 1 - (yValue - minValueY) / (maxValueY - minValueY); return {x, y}; }, - incrementX(isPageStep) { + incrementX(stepSize) { let range = color.getChannelRange(channels.xChannel); - let stepSize = isPageStep ? Math.max(range.pageSize, xChannelStep) : xChannelStep; setXValue(snapValueToStep(xValue + stepSize, range.minValue, range.maxValue, stepSize)); }, - incrementY(isPageStep) { + incrementY(stepSize) { let range = color.getChannelRange(channels.yChannel); - let stepSize = isPageStep ? Math.max(range.pageSize, yChannelStep) : yChannelStep; setYValue(snapValueToStep(yValue + stepSize, range.minValue, range.maxValue, stepSize)); }, - decrementX(isPageStep) { + decrementX(stepSize) { let range = color.getChannelRange(channels.xChannel); - let stepSize = isPageStep ? Math.max(range.pageSize, xChannelStep) : xChannelStep; setXValue(snapValueToStep(xValue - stepSize, range.minValue, range.maxValue, stepSize)); }, - decrementY(isPageStep) { + decrementY(stepSize) { let range = color.getChannelRange(channels.yChannel); - let stepSize = isPageStep ? Math.max(range.pageSize, yChannelStep) : yChannelStep; setYValue(snapValueToStep(yValue - stepSize, range.minValue, range.maxValue, stepSize)); }, setDragging(isDragging) { diff --git a/packages/@react-stately/color/src/useColorWheelState.ts b/packages/@react-stately/color/src/useColorWheelState.ts index e1d10539a1c..59ba51a5c7d 100644 --- a/packages/@react-stately/color/src/useColorWheelState.ts +++ b/packages/@react-stately/color/src/useColorWheelState.ts @@ -11,7 +11,7 @@ */ import {Color, ColorWheelProps} from '@react-types/color'; -import {parseColor} from './Color'; +import {normalizeColor, parseColor} from './Color'; import {useControlledState} from '@react-stately/utils'; import {useRef, useState} from 'react'; @@ -32,24 +32,18 @@ export interface ColorWheelState { getThumbPosition(radius: number): {x: number, y: number}, /** Increments the hue by the given amount (defaults to 1). */ - increment(minStepSize?: number): void, + increment(stepSize?: number): void, /** Decrements the hue by the given amount (defaults to 1). */ - decrement(minStepSize?: number): void, + decrement(stepSize?: number): void, /** Whether the color wheel is currently being dragged. */ readonly isDragging: boolean, /** Sets whether the color wheel is being dragged. */ setDragging(value: boolean): void, /** Returns the color that should be displayed in the color wheel instead of `value`. */ - getDisplayColor(): Color -} - -function normalizeColor(v: string | Color) { - if (typeof v === 'string') { - return parseColor(v); - } else { - return v; - } + getDisplayColor(): Color, + step: number, + pageStep: number } const DEFAULT_COLOR = parseColor('hsl(0, 100%, 50%)'); @@ -91,6 +85,7 @@ function cartesianToAngle(x: number, y: number, radius: number): number { let deg = radToDeg(Math.atan2(y / radius, x / radius)); return (deg + 360) % 360; } +const PAGE_MIN_STEP_SIZE = 6; /** * Provides state management for a color wheel component. @@ -121,8 +116,11 @@ export function useColorWheelState(props: ColorWheelProps): ColorWheelState { } } + let pageStep = PAGE_MIN_STEP_SIZE; return { value, + step, + pageStep, setValue(v) { let color = normalizeColor(v); valueRef.current = color; @@ -136,16 +134,16 @@ export function useColorWheelState(props: ColorWheelProps): ColorWheelState { getThumbPosition(radius) { return angleToCartesian(value.getChannelValue('hue'), radius); }, - increment(minStepSize: number = 0) { - let newValue = hue + Math.max(minStepSize, step); + increment(stepSize) { + let newValue = hue + Math.max(stepSize, step); if (newValue > 360) { // Make sure you can always get back to 0. newValue = 0; } setHue(newValue); }, - decrement(minStepSize: number = 0) { - let s = Math.max(minStepSize, step); + decrement(stepSize) { + let s = Math.max(stepSize, step); if (hue === 0) { // We can't just subtract step because this might be the case: // |(previous step) - 0| < step size diff --git a/packages/@react-types/shared/src/events.d.ts b/packages/@react-types/shared/src/events.d.ts index af92120dc29..d5015d28942 100644 --- a/packages/@react-types/shared/src/events.d.ts +++ b/packages/@react-types/shared/src/events.d.ts @@ -110,7 +110,15 @@ export interface FocusableProps extends FocusEvents, KeyboardEvents { interface BaseMoveEvent { /** The pointer type that triggered the move event. */ - pointerType: PointerType + pointerType: PointerType, + /** Whether the shift keyboard modifier was held during the move event. */ + shiftKey: boolean, + /** Whether the ctrl keyboard modifier was held during the move event. */ + ctrlKey: boolean, + /** Whether the meta keyboard modifier was held during the move event. */ + metaKey: boolean, + /** Whether the alt keyboard modifier was held during the move event. */ + altKey: boolean } export interface MoveStartEvent extends BaseMoveEvent { @@ -125,6 +133,7 @@ export interface MoveMoveEvent extends BaseMoveEvent { deltaX: number, /** The amount moved in the Y direction since the last event. */ deltaY: number + } export interface MoveEndEvent extends BaseMoveEvent {