Skip to content
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

Table/DataGrid: keyboard resizing improvements #28493

52 changes: 1 addition & 51 deletions apps/vr-tests-react-components/src/stories/Table.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import {
import { Button } from '@fluentui/react-button';
import { storiesOf } from '@storybook/react';
import { Steps, StoryWright } from 'storywright';
import { KeyboardResizingCurrentColumnDataAttribute } from '../../../../packages/react-components/react-table/src/hooks/useTableColumnSizing';

const items = [
{
Expand Down Expand Up @@ -635,52 +634,6 @@ const Truncate: React.FC<SharedVrTestArgs & { truncate?: boolean }> = ({ noNativ
</Table>
);

const KeyboardColumnResizingStyle: React.FC<SharedVrTestArgs> = ({ noNativeElements }) => {
return (
<Table noNativeElements={noNativeElements}>
<TableHeader>
<TableRow>
{columns.map(column => (
<TableHeaderCell key={column.columnKey} {...{ [`${KeyboardResizingCurrentColumnDataAttribute}`]: '' }}>
{column.label}
</TableHeaderCell>
))}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, i) => (
<TableRow key={item.file.label} className={`row-${i}`}>
<TableCell>
<TableCellLayout media={item.file.icon}>
{item.file.label}
<TableCellActions>
<Button icon={<EditRegular />} appearance="subtle" />
<Button icon={<MoreHorizontalRegular />} appearance="subtle" />
</TableCellActions>
</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout
media={
<Avatar name={item.author.label} badge={{ status: item.author.status as PresenceBadgeStatus }} />
}
>
{item.author.label}
</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout>{item.lastUpdated.label}</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout media={item.lastUpdate.icon}>{item.lastUpdate.label}</TableCellLayout>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
};

([true, false] as const).forEach(noNativeElements => {
const layoutName = noNativeElements ? 'flex' : 'table';
storiesOf(`Table layout ${layoutName} - cell actions`, module)
Expand Down Expand Up @@ -776,10 +729,7 @@ const KeyboardColumnResizingStyle: React.FC<SharedVrTestArgs> = ({ noNativeEleme
includeDarkMode: true,
includeHighContrast: true,
includeRtl: true,
})
.addStory('keyboard column resizing style', () => (
<KeyboardColumnResizingStyle noNativeElements={noNativeElements} />
));
});

storiesOf(`Table ${layoutName} - subtle selection`, module)
.addDecorator(story => (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "Table/DataGrid: Improve keyboard column resizing experience",
"packageName": "@fluentui/react-table",
"email": "jirivyhnalek@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { tokens } from '@fluentui/react-theme';
import type { SlotClassNames } from '@fluentui/react-utilities';
import { createCustomFocusIndicatorStyle } from '@fluentui/react-tabster';
import type { TableHeaderCellSlots, TableHeaderCellState } from './TableHeaderCell.types';
import { KeyboardResizingCurrentColumnDataAttribute } from '../../hooks/useTableColumnSizing';

export const tableHeaderCellClassName = 'fui-TableHeaderCell';
export const tableHeaderCellClassNames: SlotClassNames<TableHeaderCellSlots> = {
Expand Down Expand Up @@ -43,10 +42,6 @@ const useStyles = makeStyles({
{ selector: 'focus-within' },
),
position: 'relative',
[`[${KeyboardResizingCurrentColumnDataAttribute}]`]: {
...shorthands.borderRadius(tokens.borderRadiusMedium),
...shorthands.outline(tokens.strokeWidthThick, 'solid', tokens.colorStrokeFocus2),
},
},

rootInteractive: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ const useStyles = makeStyles({
transitionDuration: '.2s',
zIndex: 1,

// If mouse users focus on the resize handle through a context menu, we want the handle
// to be visible because the mouse might not be hovering over the handle
':focus': {
opacity: 1,
outlineStyle: 'none',
},

':hover': {
opacity: 1,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as React from 'react';
import { ArrowLeft, ArrowRight, Enter, Escape, Shift, Space } from '@fluentui/keyboard-keys';
import { useEventCallback } from '@fluentui/react-utilities';
import { ColumnResizeState, EnableKeyboardModeOnChangeCallback, TableColumnId } from './types';
import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts';
import { useFocusFinders, useTabsterAttributes } from '@fluentui/react-tabster';

const STEP = 20;
const PRECISION_MODIFIER = Shift;
Expand All @@ -11,16 +11,16 @@ const PRECISION_FACTOR = 1 / 4;
export function useKeyboardResizing(columnResizeState: ColumnResizeState) {
const [columnId, setColumnId] = React.useState<TableColumnId>();
const onChangeRef = React.useRef<EnableKeyboardModeOnChangeCallback>();
const addListenerTimeout = React.useRef<number>();
const { findPrevFocusable } = useFocusFinders();

const columnResizeStateRef = React.useRef<ColumnResizeState>(columnResizeState);
React.useEffect(() => {
columnResizeStateRef.current = columnResizeState;
}, [columnResizeState]);

const { targetDocument } = useFluent();
const [resizeHandleRefs] = React.useState(() => new Map<TableColumnId, React.RefObject<HTMLDivElement>>());

const keyboardHandler = useEventCallback((event: KeyboardEvent) => {
const keyboardHandler = useEventCallback((event: React.KeyboardEvent) => {
if (!columnId) {
return;
}
Expand All @@ -36,15 +36,15 @@ export function useKeyboardResizing(columnResizeState: ColumnResizeState) {
switch (event.key) {
case ArrowLeft:
stopEvent();
columnResizeStateRef.current.setColumnWidth(event, {
columnResizeStateRef.current.setColumnWidth(event.nativeEvent, {
columnId,
width: width - (precisionModifier ? STEP * PRECISION_FACTOR : STEP),
});
return;

case ArrowRight:
stopEvent();
columnResizeStateRef.current.setColumnWidth(event, {
columnResizeStateRef.current.setColumnWidth(event.nativeEvent, {
columnId,
width: width + (precisionModifier ? STEP * PRECISION_FACTOR : STEP),
});
Expand All @@ -54,57 +54,88 @@ export function useKeyboardResizing(columnResizeState: ColumnResizeState) {
case Enter:
case Escape:
stopEvent();
disableInteractiveMode();
// Just blur here, the onBlur handler will take care of the rest (disableInteractiveMode).
resizeHandleRefs.get(columnId)?.current?.blur();
break;
}
});

// On component unmout, cancel any timer for adding a listener (if it exists) and remove the listener
React.useEffect(
() => () => {
targetDocument?.defaultView?.clearTimeout(addListenerTimeout.current);
targetDocument?.defaultView?.removeEventListener('keydown', keyboardHandler);
},
[keyboardHandler, targetDocument?.defaultView],
);

const enableInteractiveMode = React.useCallback(
(colId: TableColumnId) => {
setColumnId(colId);
onChangeRef.current?.(colId, true);
// Create the listener in the next tick, because the event that triggered this is still propagating
// when Enter was pressed and would be caught in the keyboardHandler, disabling the keyboard mode immediately.
// No idea why this is happening, but this is a working workaround.
// Tracked here: https://github.com/microsoft/fluentui/issues/27177
addListenerTimeout.current = targetDocument?.defaultView?.setTimeout(() => {
targetDocument?.defaultView?.addEventListener('keydown', keyboardHandler);
}, 0);

const handle = resizeHandleRefs.get(colId)?.current;
if (handle) {
handle.setAttribute('tabindex', '-1');
handle.tabIndex = -1;
handle.focus();
}
},
[keyboardHandler, targetDocument?.defaultView],
[resizeHandleRefs],
);

const disableInteractiveMode = React.useCallback(() => {
if (columnId) {
onChangeRef.current?.(columnId, false);
if (!columnId) {
return;
}
// Notify the onChange listener that we are disabling interactive mode.
onChangeRef.current?.(columnId, false);
// Find the previous focusable element (table header button) and focus it.
const el = resizeHandleRefs.get(columnId)?.current;
if (el) {
findPrevFocusable(el)?.focus(); // Focus the previous focusable element (header button).
el.removeAttribute('tabindex');
}

setColumnId(undefined);
targetDocument?.defaultView?.removeEventListener('keydown', keyboardHandler);
}, [columnId, keyboardHandler, targetDocument?.defaultView]);
}, [columnId, findPrevFocusable, resizeHandleRefs]);

const toggleInteractiveMode = (colId: TableColumnId, onChange?: EnableKeyboardModeOnChangeCallback) => {
onChangeRef.current = onChange;
if (!columnId) {
enableInteractiveMode(colId);
} else if (colId && columnId !== colId) {
enableInteractiveMode(colId);
setColumnId(colId);
onChange?.(columnId, true);
} else {
disableInteractiveMode();
}
};

const getKeyboardResizingRef = React.useCallback(
(colId: TableColumnId) => {
const ref = resizeHandleRefs.get(colId) || React.createRef<HTMLDivElement>();
resizeHandleRefs.set(colId, ref);
return ref;
},
[resizeHandleRefs],
);

// This makes sure the left and right arrow keys are ignored in tabster,
// so that they can be used for resizing.
const tabsterAttrs = useTabsterAttributes({
focusable: {
ignoreKeydown: {
ArrowLeft: true,
ArrowRight: true,
},
},
});

return {
toggleInteractiveMode,
columnId,
getKeyboardResizingProps: (colId: TableColumnId, currentWidth: number) => ({
onKeyDown: keyboardHandler,
onBlur: disableInteractiveMode,
ref: getKeyboardResizingRef(colId),
role: 'separator',
'aria-label': 'Resize column',
'aria-valuetext': `${currentWidth} pixels`,
'aria-hidden': colId === columnId ? false : true,
tabIndex: colId === columnId ? 0 : undefined,
...tabsterAttrs,
}),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,15 @@ describe('useTableColumnSizing', () => {
const props = renderHookResult.result.current.columnSizing_unstable.getTableHeaderCellProps(1);
expect(props).toMatchInlineSnapshot(`
Object {
"aside": <TableResizeHandle />,
"aside": <TableResizeHandle
aria-hidden={true}
aria-label="Resize column"
aria-valuetext="150 pixels"
data-tabster="{\\"focusable\\":{\\"ignoreKeydown\\":{\\"ArrowLeft\\":true,\\"ArrowRight\\":true}}}"
onBlur={[Function]}
onKeyDown={[Function]}
role="separator"
/>,
"style": Object {
"maxWidth": 150,
"minWidth": 150,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,12 @@ import {
TableFeaturesState,
UseTableColumnSizingParams,
} from './types';

import { useMeasureElement } from './useMeasureElement';
import { useTableColumnResizeMouseHandler } from './useTableColumnResizeMouseHandler';
import { useTableColumnResizeState } from './useTableColumnResizeState';
import { useKeyboardResizing } from './useKeyboardResizing';

export const KeyboardResizingCurrentColumnDataAttribute = 'data-keyboard-resizing';

export const defaultColumnSizingState: TableColumnSizingState = {
getColumnWidths: () => [],
getOnMouseDown: () => () => null,
Expand Down Expand Up @@ -55,7 +54,7 @@ function useTableColumnSizingState<TItem>(
// Creates the mouse handler and attaches the state to it
const mouseHandler = useTableColumnResizeMouseHandler(columnResizeState);
// Creates the keyboard handler for resizing columns
const { toggleInteractiveMode, columnId: keyboardResizingColumnId } = useKeyboardResizing(columnResizeState);
const { toggleInteractiveMode, getKeyboardResizingProps } = useKeyboardResizing(columnResizeState);

const enableKeyboardMode = React.useCallback(
(columnId: TableColumnId, onChange?: EnableKeyboardModeOnChangeCallback) =>
Expand Down Expand Up @@ -83,13 +82,13 @@ function useTableColumnSizingState<TItem>(
<TableResizeHandle
onMouseDown={mouseHandler.getOnMouseDown(columnId)}
onTouchStart={mouseHandler.getOnMouseDown(columnId)}
{...getKeyboardResizingProps(columnId, col?.width || 0)}
/>
);
return col
? {
style: getColumnStyles(col),
aside,
...(keyboardResizingColumnId === columnId ? { [KeyboardResizingCurrentColumnDataAttribute]: '' } : {}),
}
: {};
},
Expand Down
Loading