diff --git a/packages/react-interactions/accessibility/src/FocusTable.js b/packages/react-interactions/accessibility/src/FocusTable.js index dc52af1cf478..cb7d7c44d84d 100644 --- a/packages/react-interactions/accessibility/src/FocusTable.js +++ b/packages/react-interactions/accessibility/src/FocusTable.js @@ -17,6 +17,7 @@ import setElementCanTab from './shared/setElementCanTab'; type FocusCellProps = { children?: React.Node, onKeyDown?: KeyboardEvent => void, + colSpan?: number, }; type FocusRowProps = { @@ -25,12 +26,12 @@ type FocusRowProps = { type FocusTableProps = {| children: React.Node, - id?: string, onKeyboardOut?: ( direction: 'left' | 'right' | 'up' | 'down', - focusTableByID: (id: string) => void, + event: KeyboardEvent, ) => void, - wrap?: boolean, + wrapX?: boolean, + wrapY?: boolean, tabScope?: ReactScope, allowModifiers?: boolean, |}; @@ -69,30 +70,59 @@ function focusScope(cell: ReactScopeMethods, event?: KeyboardEvent): void { } } -function focusCellByIndex( +// This takes into account colSpan +function focusCellByColumnIndex( row: ReactScopeMethods, - cellIndex: number, + columnIndex: number, event?: KeyboardEvent, ): void { const cells = row.getChildren(); if (cells !== null) { - const cell = cells[cellIndex]; - if (cell) { - focusScope(cell, event); + let colSize = 0; + for (let i = 0; i < cells.length; i++) { + const cell = cells[i]; + if (cell) { + colSize += cell.getProps().colSpan || 1; + if (colSize > columnIndex) { + focusScope(cell, event); + return; + } + } } } } +function getCellIndexes( + cells: Array, + currentCell: ReactScopeMethods, +): [number, number] { + let totalColSpan = 0; + for (let i = 0; i < cells.length; i++) { + const cell = cells[i]; + if (cell === currentCell) { + return [i, i + totalColSpan]; + } + const colSpan = cell.getProps().colSpan; + if (colSpan) { + totalColSpan += colSpan - 1; + } + } + return [-1, -1]; +} + function getRowCells(currentCell: ReactScopeMethods) { const row = currentCell.getParent(); if (row !== null && row.getProps().type === 'row') { const cells = row.getChildren(); if (cells !== null) { - const rowIndex = cells.indexOf(currentCell); - return [cells, rowIndex]; + const [rowIndex, rowIndexWithColSpan] = getCellIndexes( + cells, + currentCell, + ); + return [cells, rowIndex, rowIndexWithColSpan]; } } - return [null, 0]; + return [null, -1, -1]; } function getRows(currentCell: ReactScopeMethods) { @@ -107,7 +137,7 @@ function getRows(currentCell: ReactScopeMethods) { } } } - return [null, 0]; + return [null, -1, -1]; } function triggerNavigateOut( @@ -122,19 +152,7 @@ function triggerNavigateOut( const props = table.getProps(); const onKeyboardOut = props.onKeyboardOut; if (props.type === 'table' && typeof onKeyboardOut === 'function') { - const focusTableByID = (id: string) => { - const topLevelTables = table.getChildrenFromRoot(); - if (topLevelTables !== null) { - for (let i = 0; i < topLevelTables.length; i++) { - const topLevelTable = topLevelTables[i]; - if (topLevelTable.getProps().id === id) { - focusFirstCellOnTable(topLevelTable); - return; - } - } - } - }; - onKeyboardOut(direction, focusTableByID); + onKeyboardOut(direction, event); return; } } @@ -166,8 +184,8 @@ export function createFocusTable(scope: ReactScope): Array { function Table({ children, onKeyboardOut, - id, - wrap, + wrapX, + wrapY, tabScope: TabScope, allowModifiers, }): FocusTableProps { @@ -176,8 +194,8 @@ export function createFocusTable(scope: ReactScope): Array { {TabScope ? ( @@ -193,7 +211,7 @@ export function createFocusTable(scope: ReactScope): Array { return {children}; } - function Cell({children, onKeyDown}): FocusCellProps { + function Cell({children, onKeyDown, colSpan}): FocusCellProps { const scopeRef = useRef(null); const keyboard = useKeyboard({ onKeyDown(event: KeyboardEvent): void { @@ -232,18 +250,18 @@ export function createFocusTable(scope: ReactScope): Array { } switch (key) { case 'ArrowUp': { - const [cells, cellIndex] = getRowCells(currentCell); + const [cells, , cellIndexWithColSpan] = getRowCells(currentCell); if (cells !== null) { const [rows, rowIndex] = getRows(currentCell); if (rows !== null) { if (rowIndex > 0) { const row = rows[rowIndex - 1]; - focusCellByIndex(row, cellIndex, event); + focusCellByColumnIndex(row, cellIndexWithColSpan, event); } else if (rowIndex === 0) { - const wrap = getTableProps(currentCell).wrap; - if (wrap) { + const wrapY = getTableProps(currentCell).wrapY; + if (wrapY) { const row = rows[rows.length - 1]; - focusCellByIndex(row, cellIndex, event); + focusCellByColumnIndex(row, cellIndexWithColSpan, event); } else { triggerNavigateOut(currentCell, 'up', event); } @@ -253,22 +271,22 @@ export function createFocusTable(scope: ReactScope): Array { return; } case 'ArrowDown': { - const [cells, cellIndex] = getRowCells(currentCell); + const [cells, , cellIndexWithColSpan] = getRowCells(currentCell); if (cells !== null) { const [rows, rowIndex] = getRows(currentCell); if (rows !== null) { if (rowIndex !== -1) { if (rowIndex === rows.length - 1) { - const wrap = getTableProps(currentCell).wrap; - if (wrap) { + const wrapY = getTableProps(currentCell).wrapY; + if (wrapY) { const row = rows[0]; - focusCellByIndex(row, cellIndex, event); + focusCellByColumnIndex(row, cellIndexWithColSpan, event); } else { triggerNavigateOut(currentCell, 'down', event); } } else { const row = rows[rowIndex + 1]; - focusCellByIndex(row, cellIndex, event); + focusCellByColumnIndex(row, cellIndexWithColSpan, event); } } } @@ -282,8 +300,8 @@ export function createFocusTable(scope: ReactScope): Array { focusScope(cells[rowIndex - 1]); event.preventDefault(); } else if (rowIndex === 0) { - const wrap = getTableProps(currentCell).wrap; - if (wrap) { + const wrapX = getTableProps(currentCell).wrapX; + if (wrapX) { focusScope(cells[cells.length - 1], event); } else { triggerNavigateOut(currentCell, 'left', event); @@ -297,8 +315,8 @@ export function createFocusTable(scope: ReactScope): Array { if (cells !== null) { if (rowIndex !== -1) { if (rowIndex === cells.length - 1) { - const wrap = getTableProps(currentCell).wrap; - if (wrap) { + const wrapX = getTableProps(currentCell).wrapX; + if (wrapX) { focusScope(cells[0], event); } else { triggerNavigateOut(currentCell, 'right', event); @@ -317,7 +335,11 @@ export function createFocusTable(scope: ReactScope): Array { }, }); return ( - + {children} ); 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 a62562cadf54..73c378a8220f 100644 --- a/packages/react-interactions/accessibility/src/__tests__/FocusTable-test.internal.js +++ b/packages/react-interactions/accessibility/src/__tests__/FocusTable-test.internal.js @@ -46,11 +46,11 @@ describe('FocusTable', () => { TabbableScope, ); - return ({onKeyboardOut, id, wrap, allowModifiers}) => ( + return ({onKeyboardOut, wrapX, wrapY, allowModifiers}) => ( @@ -180,50 +180,45 @@ describe('FocusTable', () => { expect(document.activeElement.textContent).toBe('B1'); }); - it('handles keyboard arrow operations between tables', () => { + it('handles keyboard arrow operations between nested tables', () => { const leftSidebarRef = React.createRef(); - const FocusTable = createFocusTableComponent(); + const [ + MainFocusTable, + MainFocusTableRow, + MainFocusTableCell, + ] = createFocusTable(TabbableScope); + const SubFocusTable = createFocusTableComponent(); + const onKeyboardOut = jest.fn((direction, event) => + event.continuePropagation(), + ); function Test() { return ( -
-

Title

- -
-

Content

- { - if (direction === 'right') { - focusTableByID('right-sidebar'); - } else if (direction === 'left') { - focusTableByID('left-sidebar'); - } - }} - /> -
- -
+ + +
+

Title

+ +
+

Content

+ + + +
+ +
+
+
); } @@ -246,6 +241,7 @@ describe('FocusTable', () => { a3.keydown({ key: 'ArrowRight', }); + expect(onKeyboardOut).toHaveBeenCalledTimes(1); expect(document.activeElement.textContent).toBe('A1'); a1 = createEventTarget(document.activeElement); @@ -264,6 +260,7 @@ describe('FocusTable', () => { a3.keydown({ key: 'ArrowRight', }); + expect(onKeyboardOut).toHaveBeenCalledTimes(2); expect(document.activeElement.textContent).toBe('A1'); a1 = createEventTarget(document.activeElement); @@ -354,10 +351,10 @@ describe('FocusTable', () => { expect(document.activeElement.placeholder).toBe('B1'); }); - it('handles keyboard arrow operations with wrapping enabled', () => { + it('handles keyboard arrow operations with X wrapping enabled', () => { const Test = createFocusTableComponent(); - ReactDOM.render(, container); + ReactDOM.render(, container); const buttons = document.querySelectorAll('button'); let a1 = createEventTarget(buttons[0]); a1.focus(); @@ -385,6 +382,37 @@ describe('FocusTable', () => { expect(document.activeElement.textContent).toBe('A3'); }); + it('handles keyboard arrow operations with Y wrapping enabled', () => { + const Test = createFocusTableComponent(); + + ReactDOM.render(, container); + const buttons = document.querySelectorAll('button'); + let a1 = createEventTarget(buttons[0]); + a1.focus(); + a1.keydown({ + key: 'ArrowDown', + }); + expect(document.activeElement.textContent).toBe('B1'); + + const a2 = createEventTarget(document.activeElement); + a2.keydown({ + key: 'ArrowDown', + }); + expect(document.activeElement.textContent).toBe('C1'); + + const a3 = createEventTarget(document.activeElement); + a3.keydown({ + key: 'ArrowDown', + }); + expect(document.activeElement.textContent).toBe('A1'); + + a1 = createEventTarget(document.activeElement); + a1.keydown({ + key: 'ArrowUp', + }); + expect(document.activeElement.textContent).toBe('C1'); + }); + it('handles keyboard arrow operations mixed with tabbing', () => { const [FocusTable, FocusRow, FocusCell] = createFocusTable(TabbableScope); const beforeRef = React.createRef(); @@ -447,5 +475,93 @@ describe('FocusTable', () => { emulateBrowserTab(true); expect(document.activeElement.placeholder).toBe('B1'); }); + + it('handles keyboard arrow operations with colSpan', () => { + const firstRef = React.createRef(); + const [FocusTable, FocusRow, FocusCell] = createFocusTable(TabbableScope); + + function Test() { + return ( + <> + +
+ + + + + + + + + + + +
+
+ + + + + + + + + + + + + + +
+
+ + ); + } + + ReactDOM.render(, container); + firstRef.current.focus(); + + expect(document.activeElement.placeholder).toBe('A1'); + const a1 = createEventTarget(document.activeElement); + a1.keydown({ + key: 'ArrowRight', + }); + expect(document.activeElement.placeholder).toBe('B1'); + let b1 = createEventTarget(document.activeElement); + b1.keydown({ + key: 'ArrowRight', + }); + expect(document.activeElement.placeholder).toBe('C1'); + let c1 = createEventTarget(document.activeElement); + c1.keydown({ + key: 'ArrowDown', + }); + expect(document.activeElement.placeholder).toBe('D2'); + let d2 = createEventTarget(document.activeElement); + d2.keydown({ + key: 'ArrowUp', + }); + expect(document.activeElement.placeholder).toBe('C1'); + c1 = createEventTarget(document.activeElement); + c1.keydown({ + key: 'ArrowLeft', + }); + expect(document.activeElement.placeholder).toBe('B1'); + b1 = createEventTarget(document.activeElement); + b1.keydown({ + key: 'ArrowDown', + }); + expect(document.activeElement.placeholder).toBe('B2'); + const b2 = createEventTarget(document.activeElement); + b2.keydown({ + key: 'ArrowRight', + }); + expect(document.activeElement.placeholder).toBe('C2'); + const c2 = createEventTarget(document.activeElement); + c2.keydown({ + key: 'ArrowUp', + }); + expect(document.activeElement.placeholder).toBe('B1'); + }); }); }); diff --git a/packages/react-reconciler/src/ReactFiberScope.js b/packages/react-reconciler/src/ReactFiberScope.js index 5514a39341bc..54d6bff3d862 100644 --- a/packages/react-reconciler/src/ReactFiberScope.js +++ b/packages/react-reconciler/src/ReactFiberScope.js @@ -98,7 +98,11 @@ function collectNearestChildScopeMethods( } function isValidScopeNode(node, scope) { - return node.tag === ScopeComponent && node.type === scope; + return ( + node.tag === ScopeComponent && + node.type === scope && + node.stateNode !== null + ); } export function createScopeMethods(