/* * Copyright 2020 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ import {ChangeEvent, useCallback, useEffect, useRef} from 'react'; import {ColumnSize} from '@react-types/table'; import {DOMAttributes, FocusableElement, Key, RefObject} from '@react-types/shared'; import {focusSafely, useInteractionModality, useKeyboard, useMove, usePress} from '@react-aria/interactions'; import {getColumnHeaderId} from './utils'; import {GridNode} from '@react-types/grid'; // @ts-ignore import intlMessages from '../intl/*.json'; import {mergeProps, useDescription, useEffectEvent, useId} from '@react-aria/utils'; import {TableColumnResizeState} from '@react-stately/table'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; import {useVisuallyHidden} from '@react-aria/visually-hidden'; export interface TableColumnResizeAria { /** Props for the visually hidden input element. */ inputProps: DOMAttributes, /** Props for the resizer element. */ resizerProps: DOMAttributes, /** Whether this column is currently being resized. */ isResizing: boolean } export interface AriaTableColumnResizeProps { /** An object representing the [column header](https://www.w3.org/TR/wai-aria-1.1/#columnheader). Contains all the relevant information that makes up the column header. */ column: GridNode, /** Aria label for the hidden input. Gets read when resizing. */ 'aria-label': string, /** * Ref to the trigger if resizing was started from a column header menu. If it's provided, * focus will be returned there when resizing is done. If it isn't provided, it is assumed that the resizer is * visible at all time and keyboard resizing is started via pressing Enter on the resizer and not on focus. * */ triggerRef?: RefObject, /** If resizing is disabled. */ isDisabled?: boolean, /** Called when resizing starts. */ onResizeStart?: (widths: Map) => void, /** Called for every resize event that results in new column sizes. */ onResize?: (widths: Map) => void, /** Called when resizing ends. */ onResizeEnd?: (widths: Map) => void } /** * Provides the behavior and accessibility implementation for a table column resizer element. * @param props - Props for the resizer. * @param state - State for the table's resizable columns, as returned by `useTableColumnResizeState`. * @param ref - The ref attached to the resizer's visually hidden input element. */ export function useTableColumnResize(props: AriaTableColumnResizeProps, state: TableColumnResizeState, ref: RefObject): TableColumnResizeAria { let {column: item, triggerRef, isDisabled, onResizeStart, onResize, onResizeEnd, 'aria-label': ariaLabel} = props; const stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/table'); let id = useId(); let isResizing = state.resizingColumn === item.key; let isResizingRef = useRef(isResizing); let lastSize = useRef | null>(null); let wasFocusedOnResizeStart = useRef(false); let editModeEnabled = state.tableState.isKeyboardNavigationDisabled; let {direction} = useLocale(); let {keyboardProps} = useKeyboard({ onKeyDown: (e) => { if (editModeEnabled) { if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ' || e.key === 'Tab') { e.preventDefault(); endResize(item); } } else { // Continue propagation on keydown events so they still bubbles to useSelectableCollection and are handled there e.continuePropagation(); if (e.key === 'Enter') { startResize(item); } } } }); let startResize = useEffectEvent((item) => { if (!isResizingRef.current) { lastSize.current = state.updateResizedColumns(item.key, state.getColumnWidth(item.key)); state.startResize(item.key); state.tableState.setKeyboardNavigationDisabled(true); onResizeStart?.(lastSize.current); } isResizingRef.current = true; }); let resize = useEffectEvent((item, newWidth) => { let sizes = state.updateResizedColumns(item.key, newWidth); onResize?.(sizes); lastSize.current = sizes; }); let endResize = useEffectEvent((item) => { if (isResizingRef.current) { if (lastSize.current == null) { lastSize.current = state.updateResizedColumns(item.key, state.getColumnWidth(item.key)); } state.endResize(); state.tableState.setKeyboardNavigationDisabled(false); onResizeEnd?.(lastSize.current); isResizingRef.current = false; if (triggerRef?.current && !wasFocusedOnResizeStart.current) { // switch focus back to the column header unless the resizer was already focused when resizing started. focusSafely(triggerRef.current); } } lastSize.current = null; }); const columnResizeWidthRef = useRef(0); const {moveProps} = useMove({ onMoveStart() { columnResizeWidthRef.current = state.getColumnWidth(item.key); startResize(item); }, onMove(e) { let {deltaX, deltaY, pointerType} = e; if (direction === 'rtl') { deltaX *= -1; } if (pointerType === 'keyboard') { if (deltaY !== 0 && deltaX === 0) { deltaX = deltaY * -1; } deltaX *= 10; } // if moving up/down only, no need to resize if (deltaX !== 0) { columnResizeWidthRef.current += deltaX; resize(item, columnResizeWidthRef.current); } }, onMoveEnd(e) { let {pointerType} = e; columnResizeWidthRef.current = 0; if (pointerType === 'mouse' || (pointerType === 'touch' && wasFocusedOnResizeStart.current)) { endResize(item); } } }); let onKeyDown = useCallback((e) => { if (editModeEnabled) { moveProps.onKeyDown?.(e); } }, [editModeEnabled, moveProps]); let min = Math.floor(state.getColumnMinWidth(item.key)); let max = Math.floor(state.getColumnMaxWidth(item.key)); if (max === Infinity) { max = Number.MAX_SAFE_INTEGER; } let value = Math.floor(state.getColumnWidth(item.key)); let modality: string | null = useInteractionModality(); if (modality === 'virtual' && (typeof window !== 'undefined' && 'ontouchstart' in window)) { modality = 'touch'; } let description = triggerRef?.current == null && (modality === 'keyboard' || modality === 'virtual') && !isResizing ? stringFormatter.format('resizerDescription') : undefined; let descriptionProps = useDescription(description); let ariaProps = { 'aria-label': ariaLabel, 'aria-orientation': 'horizontal' as 'horizontal', 'aria-labelledby': `${id} ${getColumnHeaderId(state.tableState, item.key)}`, 'aria-valuetext': stringFormatter.format('columnSize', {value}), 'type': 'range', min, max, value, ...descriptionProps }; const focusInput = useCallback(() => { if (ref.current) { focusSafely(ref.current); } }, [ref]); let resizingColumn = state.resizingColumn; let prevResizingColumn = useRef(null); useEffect(() => { if (prevResizingColumn.current !== resizingColumn && resizingColumn != null && resizingColumn === item.key) { wasFocusedOnResizeStart.current = document.activeElement === ref.current; startResize(item); // Delay focusing input until Android Chrome's delayed click after touchend happens: https://bugs.chromium.org/p/chromium/issues/detail?id=1150073 let timeout = setTimeout(() => focusInput(), 0); // VoiceOver on iOS has problems focusing the input from a menu. let VOTimeout = setTimeout(focusInput, 400); return () => { clearTimeout(timeout); clearTimeout(VOTimeout); }; } prevResizingColumn.current = resizingColumn; }, [resizingColumn, item, focusInput, ref, startResize]); let onChange = (e: ChangeEvent) => { let currentWidth = state.getColumnWidth(item.key); let nextValue = parseFloat(e.target.value); if (nextValue > currentWidth) { nextValue = currentWidth + 10; } else { nextValue = currentWidth - 10; } resize(item, nextValue); }; let {pressProps} = usePress({ preventFocusOnPress: true, onPressStart: (e) => { if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey || e.pointerType === 'keyboard') { return; } if (e.pointerType === 'virtual' && state.resizingColumn != null) { endResize(item); return; } // Sometimes onPress won't trigger for quick taps on mobile so we want to focus the input so blurring away // can cancel resize mode for us. focusInput(); // If resizer is always visible, mobile screenreader user can access the visually hidden resizer directly and thus we don't need // to handle a virtual click to start the resizer. if (e.pointerType !== 'virtual') { startResize(item); } }, onPress: (e) => { if (((e.pointerType === 'touch' && wasFocusedOnResizeStart.current) || e.pointerType === 'mouse') && state.resizingColumn != null) { endResize(item); } } }); let {visuallyHiddenProps} = useVisuallyHidden(); return { resizerProps: mergeProps( keyboardProps, {...moveProps, onKeyDown}, pressProps, {style: {touchAction: 'none'}} ), inputProps: mergeProps( visuallyHiddenProps, { id, onBlur: () => { endResize(item); }, onChange, disabled: isDisabled }, ariaProps ), isResizing }; }