[react-interactions] Add FocusTable colSpan support (#17019)

This commit is contained in:
Dominic Gannaway
2019-10-07 12:04:41 +02:00
committed by GitHub
parent 4bc52ef0df
commit fff5b1ca77
3 changed files with 233 additions and 91 deletions

View File

@@ -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<ReactScopeMethods>,
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<React.Component> {
function Table({
children,
onKeyboardOut,
id,
wrap,
wrapX,
wrapY,
tabScope: TabScope,
allowModifiers,
}): FocusTableProps {
@@ -176,8 +194,8 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
<TableScope
type="table"
onKeyboardOut={onKeyboardOut}
id={id}
wrap={wrap}
wrapX={wrapX}
wrapY={wrapY}
tabScopeRef={tabScopeRef}
allowModifiers={allowModifiers}>
{TabScope ? (
@@ -193,7 +211,7 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
return <TableScope type="row">{children}</TableScope>;
}
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<React.Component> {
}
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<React.Component> {
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<React.Component> {
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<React.Component> {
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<React.Component> {
},
});
return (
<TableScope listeners={keyboard} ref={scopeRef} type="cell">
<TableScope
listeners={keyboard}
ref={scopeRef}
type="cell"
colSpan={colSpan}>
{children}
</TableScope>
);

View File

@@ -46,11 +46,11 @@ describe('FocusTable', () => {
TabbableScope,
);
return ({onKeyboardOut, id, wrap, allowModifiers}) => (
return ({onKeyboardOut, wrapX, wrapY, allowModifiers}) => (
<FocusTable
onKeyboardOut={onKeyboardOut}
id={id}
wrap={wrap}
wrapX={wrapX}
wrapY={wrapY}
allowModifiers={allowModifiers}>
<table>
<tbody>
@@ -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 (
<div>
<h1>Title</h1>
<aside ref={leftSidebarRef}>
<h2>Left Sidebar</h2>
<FocusTable
id="left-sidebar"
onKeyboardOut={(direction, focusTableByID) => {
if (direction === 'right') {
focusTableByID('content');
}
}}
/>
</aside>
<section>
<h2>Content</h2>
<FocusTable
id="content"
onKeyboardOut={(direction, focusTableByID) => {
if (direction === 'right') {
focusTableByID('right-sidebar');
} else if (direction === 'left') {
focusTableByID('left-sidebar');
}
}}
/>
</section>
<aside>
<h2>Right Sidebar</h2>
<FocusTable
id="right-sidebar"
onKeyboardOut={(direction, focusTableByID) => {
if (direction === 'left') {
focusTableByID('content');
}
}}
/>
</aside>
</div>
<MainFocusTable>
<MainFocusTableRow>
<div>
<h1>Title</h1>
<aside ref={leftSidebarRef}>
<h2>Left Sidebar</h2>
<MainFocusTableCell>
<SubFocusTable onKeyboardOut={onKeyboardOut} />
</MainFocusTableCell>
</aside>
<section>
<h2>Content</h2>
<MainFocusTableCell>
<SubFocusTable onKeyboardOut={onKeyboardOut} />
</MainFocusTableCell>
</section>
<aside>
<h2>Right Sidebar</h2>
<MainFocusTableCell>
<SubFocusTable onKeyboardOut={onKeyboardOut} />
</MainFocusTableCell>
</aside>
</div>
</MainFocusTableRow>
</MainFocusTable>
);
}
@@ -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(<Test wrap={true} />, container);
ReactDOM.render(<Test wrapX={true} />, 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(<Test wrapY={true} />, 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 (
<>
<FocusTable tabScope={TabbableScope}>
<div>
<FocusRow>
<FocusCell>
<input placeholder="A1" ref={firstRef} />
</FocusCell>
<FocusCell colSpan={2}>
<input placeholder="B1" />
</FocusCell>
<FocusCell>
<input placeholder="C1" />
</FocusCell>
</FocusRow>
</div>
<div>
<FocusRow>
<FocusCell>
<input placeholder="A2" />
</FocusCell>
<FocusCell>
<input placeholder="B2" />
</FocusCell>
<FocusCell>
<input placeholder="C2" />
</FocusCell>
<FocusCell>
<input placeholder="D2" />
</FocusCell>
</FocusRow>
</div>
</FocusTable>
</>
);
}
ReactDOM.render(<Test />, 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');
});
});
});

View File

@@ -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(