diff --git a/apis/nucleus/src/components/listbox/ListBox.jsx b/apis/nucleus/src/components/listbox/ListBox.jsx index 4477a9f9c..9c340bf23 100644 --- a/apis/nucleus/src/components/listbox/ListBox.jsx +++ b/apis/nucleus/src/components/listbox/ListBox.jsx @@ -128,6 +128,7 @@ export default function ListBox({ selections, checkboxes, doc: document, + loaderRef, }); const { layoutOptions = {} } = layout || {}; diff --git a/apis/nucleus/src/components/listbox/components/ListBoxRowColumn/ListBoxRowColumn.jsx b/apis/nucleus/src/components/listbox/components/ListBoxRowColumn/ListBoxRowColumn.jsx index 833fe4602..f4f257706 100644 --- a/apis/nucleus/src/components/listbox/components/ListBoxRowColumn/ListBoxRowColumn.jsx +++ b/apis/nucleus/src/components/listbox/components/ListBoxRowColumn/ListBoxRowColumn.jsx @@ -26,6 +26,8 @@ function RowColumn({ index, rowIndex, columnIndex, style, data }) { onMouseDown, onMouseUp, onMouseEnter, + onTouchStart, + onTouchEnd, pages, isLocked, column = false, @@ -217,6 +219,8 @@ function RowColumn({ index, rowIndex, columnIndex, style, data }) { onMouseUp={onMouseUp} onMouseEnter={onMouseEnter} onKeyDown={handleKeyDownCallback} + onTouchStart={onTouchStart} + onTouchEnd={onTouchEnd} onContextMenu={preventContextMenu} tabIndex={isFirstElement && keyboard.innerTabStops ? 0 : -1} data-n={cell?.qElemNumber} diff --git a/apis/nucleus/src/components/listbox/hooks/selections/__tests__/use-selections-interactions.test.jsx b/apis/nucleus/src/components/listbox/hooks/selections/__tests__/use-selections-interactions.test.jsx index 95a545667..543173b28 100644 --- a/apis/nucleus/src/components/listbox/hooks/selections/__tests__/use-selections-interactions.test.jsx +++ b/apis/nucleus/src/components/listbox/hooks/selections/__tests__/use-selections-interactions.test.jsx @@ -25,6 +25,7 @@ describe('use-listbox-interactions', () => { let setPages; let layout; let updateSelectionState; + let loaderRef; beforeEach(() => { jest.spyOn(global.document, 'addEventListener').mockImplementation(jest.fn()); @@ -69,13 +70,16 @@ describe('use-listbox-interactions', () => { }; ref = React.createRef(); + loaderRef = { + current: null, + }; render = async (overrides = {}) => { await act(async () => { create( ); }); @@ -94,7 +98,13 @@ describe('use-listbox-interactions', () => { await render(); const arg0 = ref.current.result; expect(Object.keys(arg0).sort()).toEqual(['interactionEvents', 'select']); - expect(Object.keys(arg0.interactionEvents).sort()).toEqual(['onMouseDown', 'onMouseEnter', 'onMouseUp']); + expect(Object.keys(arg0.interactionEvents).sort()).toEqual([ + 'onMouseDown', + 'onMouseEnter', + 'onMouseUp', + 'onTouchEnd', + 'onTouchStart', + ]); }); test('With checkboxes', async () => { await render({ checkboxes: true }); @@ -210,7 +220,13 @@ describe('use-listbox-interactions', () => { await render(); const arg0 = ref.current.result; expect(Object.keys(arg0)).toEqual(['interactionEvents', 'select']); - expect(Object.keys(arg0.interactionEvents).sort()).toEqual(['onMouseDown', 'onMouseEnter', 'onMouseUp']); + expect(Object.keys(arg0.interactionEvents).sort()).toEqual([ + 'onMouseDown', + 'onMouseEnter', + 'onMouseUp', + 'onTouchEnd', + 'onTouchStart', + ]); }); test('should select a range (in theory)', async () => { @@ -288,6 +304,70 @@ describe('use-listbox-interactions', () => { }); }); + test('Should handle range select on two finger tap', async () => { + const createPage = (s24, s25, s26, s27, s28, s29, s30, s31) => + createPageWithRange( + { qElemNumber: 24, qState: s24 }, + { qElemNumber: 25, qState: s25 }, + { qElemNumber: 26, qState: s26 }, + { qElemNumber: 27, qState: s27 }, + { qElemNumber: 28, qState: s28 }, + { qElemNumber: 29, qState: s29 }, + { qElemNumber: 30, qState: s30 }, + { qElemNumber: 31, qState: s31 } + ); + + updateSelectionState({ + pages: createPage('O', 'O', 'O', 'O', 'O', 'O', 'O', 'O'), + }); + + await render(); + + await act(() => { + const touchOne = { + target: { + closest: () => ({ getAttribute: jest.fn().mockReturnValue('24') }), + }, + }; + const touchTwo = { + target: { + closest: () => ({ getAttribute: jest.fn().mockReturnValue('29') }), + }, + }; + + ref.current.result.interactionEvents.onTouchStart({ + touches: [touchOne, touchTwo], + }); + ref.current.result.interactionEvents.onTouchEnd(); + }); + // Touch range too small + expect(listboxSelections.selectValues).not.toHaveBeenCalled(); + + await act(() => { + const touchOne = { + target: { + closest: () => ({ getAttribute: jest.fn().mockReturnValue('24') }), + }, + }; + const touchTwo = { + target: { + closest: () => ({ getAttribute: jest.fn().mockReturnValue('30') }), + }, + }; + + ref.current.result.interactionEvents.onTouchStart({ + touches: [touchOne, touchTwo], + }); + ref.current.result.interactionEvents.onTouchEnd(); + }); + expect(listboxSelections.selectValues).toHaveBeenCalledWith({ + selections, + elemNumbers: [24, 25, 26, 27, 28, 29, 30], + isSingleSelect: false, + toggle: true, + }); + }); + test('Should "toggle" checkboxes', async () => { updateSelectionState({ pages: createPageWithSingle(24, 'O'), diff --git a/apis/nucleus/src/components/listbox/hooks/selections/useSelectionsInteractions.js b/apis/nucleus/src/components/listbox/hooks/selections/useSelectionsInteractions.js index 81cd7cf37..be0d0bbbe 100644 --- a/apis/nucleus/src/components/listbox/hooks/selections/useSelectionsInteractions.js +++ b/apis/nucleus/src/components/listbox/hooks/selections/useSelectionsInteractions.js @@ -1,17 +1,47 @@ +/* eslint-disable no-underscore-dangle */ import { useEffect, useCallback, useRef } from 'react'; import { selectValues, fillRange, getElemNumbersFromPages } from './listbox-selections'; +import rowColClasses from '../../components/ListBoxRowColumn/helpers/classes'; +const dataItemSelector = `.${rowColClasses.fieldRoot}`; const getKeyAsToggleSelected = (event) => !(event?.metaKey || event?.ctrlKey); -export default function useSelectionsInteractions({ selectionState, selections, checkboxes = false, doc = document }) { +export default function useSelectionsInteractions({ + selectionState, + selections, + checkboxes = false, + doc = document, + loaderRef, +}) { const currentSelect = useRef({ startElemNumber: undefined, elemNumbers: [], isRange: false, toggle: false, active: false, + touchElemNumbers: [], + touchRangeSmall: false, }); + useEffect(() => { + if (!loaderRef.current?._listRef?._outerRef) { + return undefined; + } + const preventGestureStart = (e) => e.preventDefault(); + const preventGestureChange = (e) => e.preventDefault(); + const preventGestureEnd = (e) => e.preventDefault(); + + const listRef = loaderRef.current._listRef._outerRef; + listRef.addEventListener('gesturestart', preventGestureStart); + listRef.addEventListener('gesturechange', preventGestureChange); + listRef.addEventListener('gestureend', preventGestureEnd); + return () => { + listRef.removeEventListener('gesturestart', preventGestureStart); + listRef.removeEventListener('gesturechange', preventGestureChange); + listRef.removeEventListener('gestureend', preventGestureEnd); + }; + }, [loaderRef.current?._listRef?._outerRef]); + // eslint-disable-next-line arrow-body-style const doSelect = () => { selectionState.setSelectableValuesUpdating(); @@ -23,14 +53,17 @@ export default function useSelectionsInteractions({ selectionState, selections, }); }; + const getRange = (start, end) => { + const elemNumbersOrdered = getElemNumbersFromPages(selectionState.enginePages); + return fillRange([start, end], elemNumbersOrdered); + }; + const addToRange = (elemNumber) => { const { startElemNumber } = currentSelect.current; if (startElemNumber === elemNumber) { return; } - const rangeEnds = [currentSelect.current.startElemNumber, elemNumber]; - const elemNumbersOrdered = getElemNumbersFromPages(selectionState.enginePages); - const toMaybeAdd = fillRange(rangeEnds, elemNumbersOrdered); + const toMaybeAdd = getRange(currentSelect.current.startElemNumber, elemNumber); selectionState.updateItems(toMaybeAdd, true, currentSelect.current.elemNumbers); }; @@ -138,6 +171,58 @@ export default function useSelectionsInteractions({ selectionState, selections, addToRange(elemNumber); }, []); + const onTouchStart = useCallback((event) => { + // Handle range selection with two finger touch + if ( + currentSelect.current.active || + currentSelect.current.isRange || + selectionState.isSingleSelect || + event.touches.length <= 1 + ) { + return; + } + if (event.touches.length > 2) { + doSelect(); + return; + } + const startTouchElemNumber = Number(event.touches[0].target?.closest(dataItemSelector)?.getAttribute('data-n')); + const endTouchElemNumber = Number(event.touches[1].target?.closest(dataItemSelector)?.getAttribute('data-n')); + + if (Number.isNaN(startTouchElemNumber) || Number.isNaN(startTouchElemNumber)) { + doSelect(); + return; + } + + currentSelect.current.active = true; + const range = getRange(startTouchElemNumber, endTouchElemNumber); + if (range.length < 7) { + currentSelect.current.touchRangeSmall = true; + } + + currentSelect.current.elemNumbers = []; + currentSelect.current.touchElemNumbers = [startTouchElemNumber, endTouchElemNumber]; + }, []); + + const onTouchEnd = useCallback(() => { + if (currentSelect.current.touchElemNumbers.length !== 2) { + return; + } + if (currentSelect.current.touchRangeSmall) { + currentSelect.current.touchRangeSmall = false; + currentSelect.current.touchElemNumbers = []; + currentSelect.current.active = false; + return; + } + + const [startTouchElemNumber, endTouchElemNumber] = currentSelect.current.touchElemNumbers; + currentSelect.current.startElemNumber = startTouchElemNumber; + addToRange(endTouchElemNumber); + currentSelect.current.touchElemNumbers = []; + currentSelect.current.active = false; + currentSelect.current.toggle = true; + doSelect(); + }, []); + useEffect(() => { doc.addEventListener('mouseup', onMouseUpDoc); return () => { @@ -169,7 +254,7 @@ export default function useSelectionsInteractions({ selectionState, selections, if (checkboxes) { Object.assign(interactionEvents, { onChange }); } else { - Object.assign(interactionEvents, { onMouseUp, onMouseDown, onMouseEnter }); + Object.assign(interactionEvents, { onMouseUp, onMouseDown, onMouseEnter, onTouchStart, onTouchEnd }); } return {