Skip to content

Commit

Permalink
Table/DataGrid: keyboard resizing improvements (#28493)
Browse files Browse the repository at this point in the history
  • Loading branch information
george-cz authored Jul 24, 2023
1 parent 6720471 commit 39cf021
Show file tree
Hide file tree
Showing 14 changed files with 188 additions and 618 deletions.
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": "[email protected]",
"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:/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

0 comments on commit 39cf021

Please sign in to comment.