-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Table Column Resize via screen readers #3295
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 17 commits
7f5bcaf
fd0df6a
7246140
964bb5f
01b6f9e
5f4a54d
75fe702
7a521a3
5b94679
2d942c9
c58e258
218689e
9d1d5a4
e6594be
11951b8
b9b2043
eb4b860
48b09cb
e6a531a
4825f50
fdf1d1e
6623a3c
b48aa5b
ed01e29
fe73dee
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,7 +27,9 @@ export interface AriaTableColumnHeaderProps { | |
| /** 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. */ | ||
| node: GridNode<unknown>, | ||
| /** Whether the [column header](https://www.w3.org/TR/wai-aria-1.1/#columnheader) is contained in a virtual scroller. */ | ||
| isVirtualized?: boolean | ||
| isVirtualized?: boolean, | ||
| /** Whether the column has a menu in the header, this changes interactions with the header. */ | ||
| hasMenu?: boolean | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be less specific to menus? Should it be more like an override to disable the default sorting behavior?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe, what are the use cases other than a menu where we'd want to disable the default sorting behavior?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure, but I mainly wondered if menus were too spectrum-specific?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe, it was the ask for a future feature, custom menu options on columns. so that's where the name came from. I'm fine changing it though to |
||
| } | ||
|
|
||
| export interface TableColumnHeaderAria { | ||
|
|
@@ -43,25 +45,27 @@ export interface TableColumnHeaderAria { | |
| */ | ||
| export function useTableColumnHeader<T>(props: AriaTableColumnHeaderProps, state: TableState<T>, ref: RefObject<FocusableElement>): TableColumnHeaderAria { | ||
| let {node} = props; | ||
| let allowsResizing = node.props.allowsResizing; | ||
| let allowsSorting = node.props.allowsSorting; | ||
| // the selection cell column header needs to focus the checkbox within it but the other columns should focus the cell so that focus doesn't land on the resizer | ||
| let {gridCellProps} = useGridCell({...props, focusMode: node.props.isSelectionCell || node.props.allowsResizing || node.props.allowsSorting ? 'child' : 'cell'}, state, ref); | ||
| let {gridCellProps} = useGridCell({...props, focusMode: node.props.isSelectionCell || props.hasMenu || node.props.allowsSorting ? 'child' : 'cell'}, state, ref); | ||
|
|
||
| let isSelectionCellDisabled = node.props.isSelectionCell && state.selectionManager.selectionMode === 'single'; | ||
|
|
||
| let {pressProps} = usePress({ | ||
| // Disabled for allowsResizing because if resizing is allowed, a menu trigger is added to the column header. | ||
| isDisabled: (!(allowsSorting || allowsResizing)) || isSelectionCellDisabled, | ||
| isDisabled: !allowsSorting || isSelectionCellDisabled, | ||
| onPress() { | ||
| !allowsResizing && state.sort(node.key); | ||
| state.sort(node.key); | ||
| }, | ||
| ref | ||
| }); | ||
|
|
||
| // Needed to pick up the focusable context, enabling things like Tooltips for example | ||
| let {focusableProps} = useFocusable({}, ref); | ||
|
|
||
| if (props.hasMenu) { | ||
| pressProps = {}; | ||
| } | ||
|
|
||
| let ariaSort: DOMAttributes['aria-sort'] = null; | ||
| let isSortedColumn = state.sortDescriptor?.column === node.key; | ||
| let sortDirection = state.sortDescriptor?.direction; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,47 +12,52 @@ | |
|
|
||
| import {DOMAttributes} from '@react-types/shared'; | ||
| import {focusSafely} from '@react-aria/focus'; | ||
| import {focusWithoutScrolling, mergeProps, useGlobalListeners, useId} from '@react-aria/utils'; | ||
| import {getColumnHeaderId} from './utils'; | ||
| import {GridNode} from '@react-types/grid'; | ||
| import {mergeProps} from '@react-aria/utils'; | ||
| import {RefObject, useRef} from 'react'; | ||
| // @ts-ignore | ||
| import intlMessages from '../intl/*.json'; | ||
| import React, {ChangeEvent, RefObject, useCallback, useRef} from 'react'; | ||
| import {TableColumnResizeState, TableState} from '@react-stately/table'; | ||
| import {useKeyboard, useMove} from '@react-aria/interactions'; | ||
| import {useLocale} from '@react-aria/i18n'; | ||
| import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; | ||
|
|
||
| export interface TableColumnResizeAria { | ||
| inputProps: DOMAttributes, | ||
| resizerProps: DOMAttributes | ||
| } | ||
|
|
||
| export interface AriaTableColumnResizeProps<T> { | ||
| column: GridNode<T>, | ||
| showResizer: boolean, | ||
| label: string | ||
| label: string, | ||
| triggerRef: RefObject<HTMLDivElement> | ||
| } | ||
|
|
||
| export function useTableColumnResize<T>(props: AriaTableColumnResizeProps<T>, state: TableState<T> & TableColumnResizeState<T>, ref: RefObject<HTMLDivElement>): TableColumnResizeAria { | ||
| let {column: item, showResizer} = props; | ||
| const stateRef = useRef(null); | ||
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | ||
LFDanLu marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| export function useTableColumnResize<T>(props: AriaTableColumnResizeProps<T>, state: TableState<T>, columnState: TableColumnResizeState<T>, ref: RefObject<HTMLInputElement>): TableColumnResizeAria { | ||
| let {column: item, triggerRef} = props; | ||
| const stateRef = useRef<TableColumnResizeState<T>>(null); | ||
| // keep track of what the cursor on the body is so it can be restored back to that when done resizing | ||
| const cursor = useRef(null); | ||
| stateRef.current = state; | ||
| const cursor = useRef<string | null>(null); | ||
| stateRef.current = columnState; | ||
| const stringFormatter = useLocalizedStringFormatter(intlMessages); | ||
| let {addGlobalListener, removeGlobalListener} = useGlobalListeners(); | ||
| let id = useId(); | ||
|
|
||
| let {direction} = useLocale(); | ||
| let {keyboardProps} = useKeyboard({ | ||
| onKeyDown: (e) => { | ||
| if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ' || e.key === 'Tab') { | ||
| e.preventDefault(); | ||
| // switch focus back to the column header on anything that ends edit mode | ||
| focusSafely(ref.current.closest('[role="columnheader"]')); | ||
| focusSafely(triggerRef.current); | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| const columnResizeWidthRef = useRef(null); | ||
| const columnResizeWidthRef = useRef<number>(0); | ||
| const {moveProps} = useMove({ | ||
| onMoveStart({pointerType}) { | ||
| if (pointerType !== 'keyboard') { | ||
| stateRef.current.onColumnResizeStart(item); | ||
| } | ||
| onMoveStart() { | ||
| columnResizeWidthRef.current = stateRef.current.getColumnWidth(item.key); | ||
| cursor.current = document.body.style.cursor; | ||
| }, | ||
|
|
@@ -76,45 +81,109 @@ export function useTableColumnResize<T>(props: AriaTableColumnResizeProps<T>, st | |
| } | ||
| } | ||
| }, | ||
| onMoveEnd({pointerType}) { | ||
| if (pointerType !== 'keyboard') { | ||
| stateRef.current.onColumnResizeEnd(item); | ||
| } | ||
| onMoveEnd() { | ||
| columnResizeWidthRef.current = 0; | ||
| document.body.style.cursor = cursor.current; | ||
| } | ||
| }); | ||
| let min = Math.floor(stateRef.current.getColumnMinWidth(item.key)); | ||
| let max = Math.floor(stateRef.current.getColumnMaxWidth(item.key)); | ||
| if (max === Infinity) { | ||
| max = Number.MAX_SAFE_INTEGER; | ||
| } | ||
| let value = Math.floor(stateRef.current.getColumnWidth(item.key)); | ||
|
|
||
| let ariaProps = { | ||
| role: 'separator', | ||
| 'aria-label': props.label, | ||
| 'aria-orientation': 'vertical', | ||
| 'aria-labelledby': item.key, | ||
| 'aria-valuenow': stateRef.current.getColumnWidth(item.key), | ||
| 'aria-valuemin': stateRef.current.getColumnMinWidth(item.key), | ||
| 'aria-valuemax': stateRef.current.getColumnMaxWidth(item.key) | ||
| 'aria-orientation': 'horizontal' as 'horizontal', | ||
| 'aria-labelledby': `${id} ${getColumnHeaderId(state, item.key)}`, | ||
| 'aria-valuetext': stringFormatter.format('columnSize', {value}), | ||
| min, | ||
| max, | ||
| value | ||
| }; | ||
|
|
||
| const focusInput = useCallback(() => { | ||
| if (ref.current) { | ||
| focusWithoutScrolling(ref.current); | ||
| } | ||
| }, [ref]); | ||
|
|
||
| let onChange = (e: ChangeEvent<HTMLInputElement>) => { | ||
| let currentWidth = stateRef.current.getColumnWidth(item.key); | ||
| let nextValue = parseFloat(e.target.value); | ||
|
|
||
| if (nextValue > currentWidth) { | ||
| nextValue = currentWidth + 10; | ||
snowystinger marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } else { | ||
| nextValue = currentWidth - 10; | ||
|
|
||
snowystinger marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
| stateRef.current.onColumnResize(item, nextValue); | ||
| }; | ||
|
|
||
| let onDown = () => { | ||
| focusInput(); | ||
LFDanLu marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| addGlobalListener(window, 'mouseup', onPointerUp, false); | ||
| addGlobalListener(window, 'touchend', onUp, false); | ||
| addGlobalListener(window, 'pointerup', onPointerUp, false); | ||
| }; | ||
|
|
||
| let onPointerUp = (e) => { | ||
| // don't hide the resizer for touch since it's harder to bring back | ||
| if (e.pointerType === 'touch') { | ||
| focusInput(); | ||
| } else { | ||
| focusSafely(triggerRef.current); | ||
| } | ||
| removeGlobalListener(window, 'mouseup', onPointerUp, false); | ||
| removeGlobalListener(window, 'touchend', onUp, false); | ||
| removeGlobalListener(window, 'pointerup', onPointerUp, false); | ||
| }; | ||
|
|
||
| let onUp = () => { | ||
| focusInput(); | ||
| removeGlobalListener(window, 'mouseup', onPointerUp, false); | ||
| removeGlobalListener(window, 'touchend', onUp, false); | ||
| removeGlobalListener(window, 'pointerup', onPointerUp, false); | ||
| }; | ||
|
|
||
| return { | ||
| resizerProps: { | ||
| ...mergeProps( | ||
| moveProps, | ||
| { | ||
| onFocus: () => { | ||
| // useMove calls onMoveStart for every keypress, but we want resize start to only be called when we start resize mode | ||
| // call instead during focus and blur | ||
| stateRef.current.onColumnResizeStart(item); | ||
| state.setKeyboardNavigationDisabled(true); | ||
| }, | ||
| onBlur: () => { | ||
| stateRef.current.onColumnResizeEnd(item); | ||
| state.setKeyboardNavigationDisabled(false); | ||
| }, | ||
| tabIndex: showResizer ? 0 : undefined | ||
| resizerProps: mergeProps( | ||
| keyboardProps, | ||
| moveProps, | ||
| { | ||
| onMouseDown: (e: React.MouseEvent<HTMLElement>) => { | ||
| if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey) { | ||
| return; | ||
| } | ||
| onDown(); | ||
|
||
| }, | ||
| keyboardProps, | ||
| ariaProps | ||
| ) | ||
| } | ||
| onPointerDown: (e: React.PointerEvent<HTMLElement>) => { | ||
| if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey) { | ||
| return; | ||
| } | ||
| onDown(); | ||
| }, | ||
| onTouchStart: () => {onDown();} | ||
| } | ||
| ), | ||
| inputProps: mergeProps( | ||
| { | ||
| id, | ||
| onFocus: () => { | ||
| // useMove calls onMoveStart for every keypress, but we want resize start to only be called when we start resize mode | ||
| // call instead during focus and blur | ||
| stateRef.current.onColumnResizeStart(item); | ||
| state.setKeyboardNavigationDisabled(true); | ||
| }, | ||
| onBlur: () => { | ||
| stateRef.current.onColumnResizeEnd(item); | ||
| state.setKeyboardNavigationDisabled(false); | ||
| }, | ||
| onChange | ||
| }, | ||
| ariaProps | ||
| ) | ||
| }; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.