UNPKG

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