From b9811ed5bc710f6318eed6292ad76e4874ac7f99 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Thu, 26 Sep 2019 15:15:48 +0200 Subject: [PATCH] [react-interactions] Add wrapping support to FocusList/FocusTable (#16903) --- .../accessibility/src/FocusList.js | 46 +++++++---- .../accessibility/src/FocusTable.js | 78 +++++++++++++++---- .../src/__tests__/FocusList-test.internal.js | 35 ++++++++- .../src/__tests__/FocusTable-test.internal.js | 35 ++++++++- 4 files changed, 160 insertions(+), 34 deletions(-) diff --git a/packages/react-interactions/accessibility/src/FocusList.js b/packages/react-interactions/accessibility/src/FocusList.js index 0a6ffd3fee3d1..2338436bd3d03 100644 --- a/packages/react-interactions/accessibility/src/FocusList.js +++ b/packages/react-interactions/accessibility/src/FocusList.js @@ -15,19 +15,22 @@ import {useKeyboard} from 'react-interactions/events/keyboard'; type FocusItemProps = { children?: React.Node, + onKeyDown?: KeyboardEvent => void, }; type FocusListProps = {| children: React.Node, portrait: boolean, + wrap?: boolean, |}; const {useRef} = React; -function focusListItem(cell: ReactScopeMethods): void { +function focusListItem(cell: ReactScopeMethods, event: KeyboardEvent): void { const tabbableNodes = cell.getScopedNodes(); if (tabbableNodes !== null && tabbableNodes.length > 0) { tabbableNodes[0].focus(); + event.preventDefault(); } } @@ -38,7 +41,10 @@ function getPreviousListItem( const items = list.getChildren(); if (items !== null) { const currentItemIndex = items.indexOf(currentItem); - if (currentItemIndex > 0) { + const wrap = getListWrapProp(currentItem); + if (currentItemIndex === 0 && wrap) { + return items[items.length - 1] || null; + } else if (currentItemIndex > 0) { return items[currentItemIndex - 1] || null; } } @@ -52,25 +58,38 @@ function getNextListItem( const items = list.getChildren(); if (items !== null) { const currentItemIndex = items.indexOf(currentItem); - if (currentItemIndex !== -1 && currentItemIndex !== items.length - 1) { + const wrap = getListWrapProp(currentItem); + const end = currentItemIndex === items.length - 1; + if (end && wrap) { + return items[0] || null; + } else if (currentItemIndex !== -1 && !end) { return items[currentItemIndex + 1] || null; } } return null; } +function getListWrapProp(currentItem: ReactScopeMethods): boolean { + const list = currentItem.getParent(); + if (list !== null) { + const listProps = list.getProps(); + return (listProps.type === 'list' && listProps.wrap) || false; + } + return false; +} + export function createFocusList(scope: ReactScope): Array { const TableScope = React.unstable_createScope(scope.fn); - function List({children, portrait}): FocusListProps { + function List({children, portrait, wrap}): FocusListProps { return ( - + {children} ); } - function Item({children}): FocusItemProps { + function Item({children, onKeyDown}): FocusItemProps { const scopeRef = useRef(null); const keyboard = useKeyboard({ onKeyDown(event: KeyboardEvent): void { @@ -88,8 +107,7 @@ export function createFocusList(scope: ReactScope): Array { currentItem, ); if (previousListItem) { - event.preventDefault(); - focusListItem(previousListItem); + focusListItem(previousListItem, event); return; } } @@ -99,8 +117,7 @@ export function createFocusList(scope: ReactScope): Array { if (portrait) { const nextListItem = getNextListItem(list, currentItem); if (nextListItem) { - event.preventDefault(); - focusListItem(nextListItem); + focusListItem(nextListItem, event); return; } } @@ -113,8 +130,7 @@ export function createFocusList(scope: ReactScope): Array { currentItem, ); if (previousListItem) { - event.preventDefault(); - focusListItem(previousListItem); + focusListItem(previousListItem, event); return; } } @@ -124,8 +140,7 @@ export function createFocusList(scope: ReactScope): Array { if (!portrait) { const nextListItem = getNextListItem(list, currentItem); if (nextListItem) { - event.preventDefault(); - focusListItem(nextListItem); + focusListItem(nextListItem, event); return; } } @@ -134,6 +149,9 @@ export function createFocusList(scope: ReactScope): Array { } } } + if (onKeyDown) { + onKeyDown(event); + } event.continuePropagation(); }, }); diff --git a/packages/react-interactions/accessibility/src/FocusTable.js b/packages/react-interactions/accessibility/src/FocusTable.js index a62d06bb3a9ea..df50e1ebca984 100644 --- a/packages/react-interactions/accessibility/src/FocusTable.js +++ b/packages/react-interactions/accessibility/src/FocusTable.js @@ -15,6 +15,7 @@ import {useKeyboard} from 'react-interactions/events/keyboard'; type FocusCellProps = { children?: React.Node, + onKeyDown?: KeyboardEvent => void, }; type FocusRowProps = { @@ -28,6 +29,7 @@ type FocusTableProps = {| direction: 'left' | 'right' | 'up' | 'down', focusTableByID: (id: string) => void, ) => void, + wrap?: boolean, |}; const {useRef} = React; @@ -54,19 +56,26 @@ export function focusFirstCellOnTable(table: ReactScopeMethods): void { } } -function focusCell(cell: ReactScopeMethods): void { +function focusCell(cell: ReactScopeMethods, event?: KeyboardEvent): void { const tabbableNodes = cell.getScopedNodes(); if (tabbableNodes !== null && tabbableNodes.length > 0) { tabbableNodes[0].focus(); + if (event) { + event.preventDefault(); + } } } -function focusCellByIndex(row: ReactScopeMethods, cellIndex: number): void { +function focusCellByIndex( + row: ReactScopeMethods, + cellIndex: number, + event?: KeyboardEvent, +): void { const cells = row.getChildren(); if (cells !== null) { const cell = cells[cellIndex]; if (cell) { - focusCell(cell); + focusCell(cell, event); } } } @@ -130,12 +139,27 @@ function triggerNavigateOut( event.continuePropagation(); } +function getTableWrapProp(currentCell: ReactScopeMethods): boolean { + const row = currentCell.getParent(); + if (row !== null && row.getProps().type === 'row') { + const table = row.getParent(); + if (table !== null) { + return table.getProps().wrap || false; + } + } + return false; +} + export function createFocusTable(scope: ReactScope): Array { const TableScope = React.unstable_createScope(scope.fn); - function Table({children, onKeyboardOut, id}): FocusTableProps { + function Table({children, onKeyboardOut, id, wrap}): FocusTableProps { return ( - + {children} ); @@ -145,7 +169,7 @@ export function createFocusTable(scope: ReactScope): Array { return {children}; } - function Cell({children}): FocusCellProps { + function Cell({children, onKeyDown}): FocusCellProps { const scopeRef = useRef(null); const keyboard = useKeyboard({ onKeyDown(event: KeyboardEvent): void { @@ -162,10 +186,15 @@ export function createFocusTable(scope: ReactScope): Array { if (rows !== null) { if (rowIndex > 0) { const row = rows[rowIndex - 1]; - focusCellByIndex(row, cellIndex); - event.preventDefault(); + focusCellByIndex(row, cellIndex, event); } else if (rowIndex === 0) { - triggerNavigateOut(currentCell, 'up', event); + const wrap = getTableWrapProp(currentCell); + if (wrap) { + const row = rows[rows.length - 1]; + focusCellByIndex(row, cellIndex, event); + } else { + triggerNavigateOut(currentCell, 'up', event); + } } } } @@ -178,11 +207,16 @@ export function createFocusTable(scope: ReactScope): Array { if (rows !== null) { if (rowIndex !== -1) { if (rowIndex === rows.length - 1) { - triggerNavigateOut(currentCell, 'down', event); + const wrap = getTableWrapProp(currentCell); + if (wrap) { + const row = rows[0]; + focusCellByIndex(row, cellIndex, event); + } else { + triggerNavigateOut(currentCell, 'down', event); + } } else { const row = rows[rowIndex + 1]; - focusCellByIndex(row, cellIndex); - event.preventDefault(); + focusCellByIndex(row, cellIndex, event); } } } @@ -196,7 +230,12 @@ export function createFocusTable(scope: ReactScope): Array { focusCell(cells[rowIndex - 1]); event.preventDefault(); } else if (rowIndex === 0) { - triggerNavigateOut(currentCell, 'left', event); + const wrap = getTableWrapProp(currentCell); + if (wrap) { + focusCell(cells[cells.length - 1], event); + } else { + triggerNavigateOut(currentCell, 'left', event); + } } } return; @@ -206,16 +245,23 @@ export function createFocusTable(scope: ReactScope): Array { if (cells !== null) { if (rowIndex !== -1) { if (rowIndex === cells.length - 1) { - triggerNavigateOut(currentCell, 'right', event); + const wrap = getTableWrapProp(currentCell); + if (wrap) { + focusCell(cells[0], event); + } else { + triggerNavigateOut(currentCell, 'right', event); + } } else { - focusCell(cells[rowIndex + 1]); - event.preventDefault(); + focusCell(cells[rowIndex + 1], event); } } } return; } } + if (onKeyDown) { + onKeyDown(event); + } }, }); return ( diff --git a/packages/react-interactions/accessibility/src/__tests__/FocusList-test.internal.js b/packages/react-interactions/accessibility/src/__tests__/FocusList-test.internal.js index 429b6a0d7bb0e..e880d06c0437c 100644 --- a/packages/react-interactions/accessibility/src/__tests__/FocusList-test.internal.js +++ b/packages/react-interactions/accessibility/src/__tests__/FocusList-test.internal.js @@ -43,8 +43,8 @@ describe('FocusList', () => { function createFocusListComponent() { const [FocusList, FocusItem] = createFocusList(TabbableScope); - return ({portrait}) => ( - + return ({portrait, wrap}) => ( +
  • Item 1
  • @@ -125,5 +125,36 @@ describe('FocusList', () => { }); expect(document.activeElement.textContent).toBe('Item 3'); }); + + it('handles keyboard arrow operations (portrait) with wrapping enabled', () => { + const Test = createFocusListComponent(); + + ReactDOM.render(, container); + const listItems = document.querySelectorAll('li'); + let firstListItem = createEventTarget(listItems[0]); + firstListItem.focus(); + firstListItem.keydown({ + key: 'ArrowDown', + }); + expect(document.activeElement.textContent).toBe('Item 2'); + + const secondListItem = createEventTarget(document.activeElement); + secondListItem.keydown({ + key: 'ArrowDown', + }); + expect(document.activeElement.textContent).toBe('Item 3'); + + const thirdListItem = createEventTarget(document.activeElement); + thirdListItem.keydown({ + key: 'ArrowDown', + }); + expect(document.activeElement.textContent).toBe('Item 1'); + + firstListItem = createEventTarget(document.activeElement); + firstListItem.keydown({ + key: 'ArrowUp', + }); + expect(document.activeElement.textContent).toBe('Item 3'); + }); }); }); diff --git a/packages/react-interactions/accessibility/src/__tests__/FocusTable-test.internal.js b/packages/react-interactions/accessibility/src/__tests__/FocusTable-test.internal.js index c88b5b83b076a..ad3db8d8aa5bb 100644 --- a/packages/react-interactions/accessibility/src/__tests__/FocusTable-test.internal.js +++ b/packages/react-interactions/accessibility/src/__tests__/FocusTable-test.internal.js @@ -45,8 +45,8 @@ describe('FocusTable', () => { TabbableScope, ); - return ({onKeyboardOut, id}) => ( - + return ({onKeyboardOut, id, wrap}) => ( + @@ -326,5 +326,36 @@ describe('FocusTable', () => { }); expect(document.activeElement.placeholder).toBe('B1'); }); + + it('handles keyboard arrow operations with wrapping enabled', () => { + const Test = createFocusTableComponent(); + + ReactDOM.render(, container); + const buttons = document.querySelectorAll('button'); + let a1 = createEventTarget(buttons[0]); + a1.focus(); + a1.keydown({ + key: 'ArrowRight', + }); + expect(document.activeElement.textContent).toBe('A2'); + + const a2 = createEventTarget(document.activeElement); + a2.keydown({ + key: 'ArrowRight', + }); + expect(document.activeElement.textContent).toBe('A3'); + + const a3 = createEventTarget(document.activeElement); + a3.keydown({ + key: 'ArrowRight', + }); + expect(document.activeElement.textContent).toBe('A1'); + + a1 = createEventTarget(document.activeElement); + a1.keydown({ + key: 'ArrowLeft', + }); + expect(document.activeElement.textContent).toBe('A3'); + }); }); });