mirror of
https://github.com/zebrajr/react.git
synced 2026-01-15 12:15:22 +00:00
[react-interactions] Add FocusTable colSpan support (#17019)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user