@awsui/components-react
Version:
On July 19th, 2022, we launched [Cloudscape Design System](https://cloudscape.design). Cloudscape is an evolution of AWS-UI. It consists of user interface guidelines, front-end components, design resources, and development tools for building intuitive, en
287 lines • 14.4 kB
JavaScript
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useRef } from 'react';
import { useEffect, useMemo } from 'react';
import { useStableCallback } from '@awsui/component-toolkit/internal';
import { getAllFocusables } from '../../internal/components/focus-lock/utils';
import { SingleTabStopNavigationProvider, } from '../../internal/context/single-tab-stop-navigation-context';
import { KeyCode } from '../../internal/keycode';
import handleKey, { isEventLike } from '../../internal/utils/handle-key';
import { nodeBelongs } from '../../internal/utils/node-belongs';
import { defaultIsSuppressed, findTableRowByAriaRowIndex, findTableRowCellByAriaColIndex, focusNextElement, getClosestCell, isElementDisabled, isTableCell, } from './utils';
/**
* Makes table navigable with keyboard commands.
* See grid-navigation.md
*/
export function GridNavigationProvider({ keyboardNavigation, pageSize, getTable, children }) {
const navigationAPI = useRef(null);
const gridNavigation = useMemo(() => new GridNavigationProcessor(navigationAPI), []);
const getTableStable = useStableCallback(getTable);
// Initialize the processor with the table container assuming it is mounted synchronously and only once.
useEffect(() => {
if (keyboardNavigation) {
const table = getTableStable();
table && gridNavigation.init(table);
}
return () => gridNavigation.cleanup();
}, [keyboardNavigation, gridNavigation, getTableStable]);
// Notify the processor of the props change.
useEffect(() => {
gridNavigation.update({ pageSize });
}, [gridNavigation, pageSize]);
// Notify the processor of the new render.
useEffect(() => {
if (keyboardNavigation) {
gridNavigation.refresh();
}
});
return (React.createElement(SingleTabStopNavigationProvider, { ref: navigationAPI, navigationActive: keyboardNavigation, getNextFocusTarget: gridNavigation.getNextFocusTarget, isElementSuppressed: gridNavigation.isElementSuppressed, onRegisterFocusable: gridNavigation.onRegisterFocusable, onUnregisterActive: gridNavigation.onUnregisterActive }, children));
}
/**
* This helper encapsulates the grid navigation behaviors which are:
* 1. Responding to keyboard commands and moving the focus accordingly;
* 2. Muting table interactive elements for only one to be user-focusable at a time;
* 3. Suppressing the above behaviors when focusing an element inside a dialog or when instructed explicitly.
*/
class GridNavigationProcessor {
constructor(navigationAPI) {
// Props
this._pageSize = 0;
this._table = null;
// State
this.focusedCell = null;
this.focusInside = false;
this.keepUserIndex = false;
this.onRegisterFocusable = (focusableElement) => {
var _a;
if (!this.focusInside) {
return;
}
// When newly registered element belongs to the focused cell the focus must transition to it.
const focusedElement = (_a = this.focusedCell) === null || _a === void 0 ? void 0 : _a.element;
if (focusedElement && isTableCell(focusedElement) && focusedElement.contains(focusableElement)) {
// Scroll is unnecessary when moving focus from a cell to element within the cell.
focusableElement.focus({ preventScroll: true });
}
};
this.onUnregisterActive = () => {
// If the focused cell appears to be no longer attached to the table we need to re-apply
// focus to a cell with the same or closest position.
if (this.focusedCell && !nodeBelongs(this.table, this.focusedCell.element)) {
this.moveFocusBy(this.focusedCell, { x: 0, y: 0 });
}
};
this.getNextFocusTarget = () => {
var _a;
const cell = this.focusedCell;
const firstTableCell = this.table.querySelector('td,th');
// A single element of the table is made user-focusable.
// It defaults to the first interactive element of the first cell or the first cell itself otherwise.
let focusTarget = (_a = (firstTableCell && this.getFocusablesFrom(firstTableCell)[0])) !== null && _a !== void 0 ? _a : firstTableCell;
// When a navigation-focused element is present in the table it is used for user-navigation instead.
if (cell) {
focusTarget = this.getNextFocusable(cell, { x: 0, y: 0 });
}
return focusTarget;
};
this.isElementSuppressed = (element) => {
// Omit calculation as irrelevant until the table receives focus.
if (!this.focusedCell) {
return false;
}
return !element || defaultIsSuppressed(element);
};
this.onFocusin = (event) => {
var _a;
this.focusInside = true;
if (!(event.target instanceof HTMLElement)) {
return;
}
this.updateFocusedCell(event.target);
if (!this.focusedCell) {
return;
}
(_a = this._navigationAPI.current) === null || _a === void 0 ? void 0 : _a.updateFocusTarget();
// Focusing on cell is not eligible when it contains focusable elements in the content.
// If content focusables are available - move the focus to the first one.
const focusedElement = this.focusedCell.element;
const nextTarget = isTableCell(focusedElement) ? this.getFocusablesFrom(focusedElement)[0] : null;
if (nextTarget) {
// Scroll is unnecessary when moving focus from a cell to element within the cell.
nextTarget.focus({ preventScroll: true });
}
else {
this.keepUserIndex = false;
}
};
this.onFocusout = () => {
this.focusInside = false;
};
this.onKeydown = (event) => {
if (!this.focusedCell) {
return;
}
const keys = [
KeyCode.up,
KeyCode.down,
KeyCode.left,
KeyCode.right,
KeyCode.pageUp,
KeyCode.pageDown,
KeyCode.home,
KeyCode.end,
];
const ctrlKey = event.ctrlKey ? 1 : 0;
const altKey = event.altKey ? 1 : 0;
const shiftKey = event.shiftKey ? 1 : 0;
const metaKey = event.metaKey ? 1 : 0;
const modifiersPressed = ctrlKey + altKey + shiftKey + metaKey;
const invalidModifierCombination = (modifiersPressed && !event.ctrlKey) ||
(event.ctrlKey && event.keyCode !== KeyCode.home && event.keyCode !== KeyCode.end);
if (invalidModifierCombination ||
this.isElementSuppressed(document.activeElement) ||
!this.isRegistered(document.activeElement) ||
keys.indexOf(event.keyCode) === -1) {
return;
}
const from = this.focusedCell;
event.preventDefault();
isEventLike(event) &&
handleKey(event, {
onBlockStart: () => this.moveFocusBy(from, { y: -1, x: 0 }),
onBlockEnd: () => this.moveFocusBy(from, { y: 1, x: 0 }),
onInlineStart: () => this.moveFocusBy(from, { y: 0, x: -1 }),
onInlineEnd: () => this.moveFocusBy(from, { y: 0, x: 1 }),
onPageUp: () => this.moveFocusBy(from, { y: -this.pageSize, x: 0 }),
onPageDown: () => this.moveFocusBy(from, { y: this.pageSize, x: 0 }),
onHome: () => event.ctrlKey
? this.moveFocusBy(from, { y: -Infinity, x: -Infinity })
: this.moveFocusBy(from, { y: 0, x: -Infinity }),
onEnd: () => event.ctrlKey
? this.moveFocusBy(from, { y: Infinity, x: Infinity })
: this.moveFocusBy(from, { y: 0, x: Infinity }),
});
};
this._navigationAPI = navigationAPI;
}
init(table) {
this._table = table;
const controller = new AbortController();
this.table.addEventListener('focusin', this.onFocusin, { signal: controller.signal });
this.table.addEventListener('focusout', this.onFocusout, { signal: controller.signal });
this.table.addEventListener('keydown', this.onKeydown, { signal: controller.signal });
this.cleanup = () => {
controller.abort();
};
}
cleanup() {
// Do nothing before initialized.
}
update({ pageSize }) {
this._pageSize = pageSize;
}
refresh() {
// Timeout ensures the newly rendered content elements are registered.
setTimeout(() => {
var _a, _b;
if (this._table) {
// Update focused cell indices in case table rows, columns, or firstIndex change.
this.updateFocusedCell((_a = this.focusedCell) === null || _a === void 0 ? void 0 : _a.element);
(_b = this._navigationAPI.current) === null || _b === void 0 ? void 0 : _b.updateFocusTarget();
}
}, 0);
}
get pageSize() {
return this._pageSize;
}
get table() {
if (!this._table) {
throw new Error('Invariant violation: GridNavigationProcessor is used before initialization.');
}
return this._table;
}
moveFocusBy(cell, delta) {
// For vertical moves preserve column- and element indices set by user.
// It allows keeping indices while moving over disabled actions or cells with colspan > 1.
if (delta.y !== 0 && delta.x === 0) {
this.keepUserIndex = true;
}
focusNextElement(this.getNextFocusable(cell, delta));
}
isRegistered(element) {
var _a, _b;
return !element || ((_b = (_a = this._navigationAPI.current) === null || _a === void 0 ? void 0 : _a.isRegistered(element)) !== null && _b !== void 0 ? _b : false);
}
updateFocusedCell(focusedElement) {
var _a, _b, _c, _d, _e, _f;
if (!focusedElement) {
return;
}
const cellElement = getClosestCell(focusedElement);
const rowElement = cellElement === null || cellElement === void 0 ? void 0 : cellElement.closest('tr');
if (!cellElement || !rowElement) {
return;
}
const colIndex = parseInt((_a = cellElement.getAttribute('aria-colindex')) !== null && _a !== void 0 ? _a : '');
const rowIndex = parseInt((_b = rowElement.getAttribute('aria-rowindex')) !== null && _b !== void 0 ? _b : '');
if (isNaN(colIndex) || isNaN(rowIndex)) {
return;
}
const cellFocusables = this.getFocusablesFrom(cellElement);
const elementIndex = cellFocusables.indexOf(focusedElement);
const prevColIndex = (_d = (_c = this.focusedCell) === null || _c === void 0 ? void 0 : _c.colIndex) !== null && _d !== void 0 ? _d : -1;
const prevElementIndex = (_f = (_e = this.focusedCell) === null || _e === void 0 ? void 0 : _e.elementIndex) !== null && _f !== void 0 ? _f : -1;
this.focusedCell = {
rowIndex,
colIndex: this.keepUserIndex && prevColIndex !== -1 ? prevColIndex : colIndex,
elementIndex: this.keepUserIndex && prevElementIndex !== -1 ? prevElementIndex : elementIndex,
element: focusedElement,
};
}
getNextFocusable(from, delta) {
var _a;
// Find next row to move focus into (can be null if the top/bottom is reached).
const targetAriaRowIndex = from.rowIndex + delta.y;
const targetRow = findTableRowByAriaRowIndex(this.table, targetAriaRowIndex, delta.y);
if (!targetRow) {
return null;
}
// Return next interactive cell content element if available.
const cellElement = getClosestCell(from.element);
const cellFocusables = cellElement ? this.getFocusablesFrom(cellElement) : [];
const nextElementIndex = from.elementIndex + delta.x;
const isValidDirection = !!delta.x;
const isValidIndex = from.elementIndex !== -1 && 0 <= nextElementIndex && nextElementIndex < cellFocusables.length;
const isTargetDifferent = from.element !== cellFocusables[nextElementIndex];
if (isValidDirection && isValidIndex && isTargetDifferent) {
return cellFocusables[nextElementIndex];
}
// Find next cell to focus or move focus into (can be null if the left/right edge is reached).
const targetAriaColIndex = from.colIndex + delta.x;
const targetCell = findTableRowCellByAriaColIndex(targetRow, targetAriaColIndex, delta.x);
if (!targetCell) {
return null;
}
// When target cell matches the current cell it means we reached the left or right boundary.
if (targetCell === cellElement && delta.x !== 0) {
return null;
}
const targetCellFocusables = this.getFocusablesFrom(targetCell);
// When delta.x = 0 keep element index if possible.
let focusIndex = from.elementIndex;
// Use first element index when moving to the right or to extreme left.
if ((isFinite(delta.x) && delta.x > 0) || delta.x === -Infinity) {
focusIndex = 0;
}
// Use last element index when moving to the left or to extreme right.
if ((isFinite(delta.x) && delta.x < 0) || delta.x === Infinity) {
focusIndex = targetCellFocusables.length - 1;
}
return (_a = targetCellFocusables[focusIndex]) !== null && _a !== void 0 ? _a : targetCell;
}
getFocusablesFrom(target) {
const isElementRegistered = (element) => { var _a; return (_a = this._navigationAPI.current) === null || _a === void 0 ? void 0 : _a.isRegistered(element); };
return getAllFocusables(target).filter(el => isElementRegistered(el) && !isElementDisabled(el));
}
}
//# sourceMappingURL=grid-navigation.js.map