Skip to content

Commit 5a2c4d9

Browse files
authored
Resize column via keyboard (#3754)
Resize column on `Ctrl + ArrowRight/Left`
1 parent 56a420e commit 5a2c4d9

File tree

8 files changed

+92
-21
lines changed

8 files changed

+92
-21
lines changed

src/DataGrid.tsx

+4-5
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
canExitGrid,
2929
createCellEvent,
3030
getColSpan,
31+
getLeftRightKey,
3132
getNextSelectedCellPosition,
3233
isCtrlKeyHeldDown,
3334
isDefaultCellInput,
@@ -377,9 +378,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
377378
const summaryRowsHeight = summaryRowsCount * summaryRowHeight;
378379
const clientHeight = gridHeight - headerRowsHeight - summaryRowsHeight;
379380
const isSelectable = selectedRows != null && onSelectedRowsChange != null;
380-
const isRtl = direction === 'rtl';
381-
const leftKey = isRtl ? 'ArrowRight' : 'ArrowLeft';
382-
const rightKey = isRtl ? 'ArrowLeft' : 'ArrowRight';
381+
const { leftKey, rightKey } = getLeftRightKey(direction);
383382
const ariaRowCount = rawAriaRowCount ?? headerRowsCount + rows.length + summaryRowsCount;
384383

385384
const defaultGridComponents = useMemo(
@@ -689,7 +688,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
689688
assertIsValidKeyGetter<R, K>(rowKeyGetter);
690689
const rowKey = rowKeyGetter(row);
691690
selectRow({ row, checked: !selectedRows.has(rowKey), isShiftClick: false });
692-
// do not scroll
691+
// prevent scrolling
693692
event.preventDefault();
694693
return;
695694
}
@@ -820,7 +819,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
820819
cellNavigationMode = 'CHANGE_ROW';
821820
}
822821

823-
// Do not allow focus to leave and prevent scrolling
822+
// prevent scrolling and do not allow focus to leave
824823
event.preventDefault();
825824

826825
const ctrlKey = isCtrlKeyHeldDown(event);

src/HeaderCell.tsx

+21-2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
getCellStyle,
99
getHeaderCellRowSpan,
1010
getHeaderCellStyle,
11+
getLeftRightKey,
12+
isCtrlKeyHeldDown,
1113
stopPropagation
1214
} from './utils';
1315
import type { CalculatedColumn, SortColumn } from './types';
@@ -160,10 +162,26 @@ export default function HeaderCell<R, SR>({
160162
}
161163

162164
function onKeyDown(event: React.KeyboardEvent<HTMLSpanElement>) {
163-
if (event.key === ' ' || event.key === 'Enter') {
165+
const { key } = event;
166+
if (sortable && (key === ' ' || key === 'Enter')) {
164167
// prevent scrolling
165168
event.preventDefault();
166169
onSort(event.ctrlKey || event.metaKey);
170+
} else if (
171+
resizable &&
172+
isCtrlKeyHeldDown(event) &&
173+
(key === 'ArrowLeft' || key === 'ArrowRight')
174+
) {
175+
// prevent navigation
176+
// TODO: check if we can use `preventDefault` instead
177+
event.stopPropagation();
178+
const { width } = event.currentTarget.getBoundingClientRect();
179+
const { leftKey } = getLeftRightKey(direction);
180+
const offset = key === leftKey ? -10 : 10;
181+
const newWidth = clampColumnWidth(width + offset, column);
182+
if (newWidth !== width) {
183+
onColumnResize(column, newWidth);
184+
}
167185
}
168186
}
169187

@@ -192,6 +210,7 @@ export default function HeaderCell<R, SR>({
192210
if (event.dataTransfer.types.includes(dragDropKey.toLowerCase())) {
193211
const sourceKey = event.dataTransfer.getData(dragDropKey.toLowerCase());
194212
if (sourceKey !== column.key) {
213+
// prevent the browser from redirecting in some cases
195214
event.preventDefault();
196215
onColumnsReorder?.(sourceKey, column.key);
197216
}
@@ -242,7 +261,7 @@ export default function HeaderCell<R, SR>({
242261
}}
243262
onFocus={handleFocus}
244263
onClick={onClick}
245-
onKeyDown={sortable ? onKeyDown : undefined}
264+
onKeyDown={onKeyDown}
246265
{...draggableProps}
247266
>
248267
{column.renderHeaderCell({

src/TreeDataGrid.tsx

+4-5
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useCallback, useMemo } from 'react';
22
import type { Key } from 'react';
33

44
import { useLatestFunc } from './hooks';
5-
import { assertIsValidKeyGetter } from './utils';
5+
import { assertIsValidKeyGetter, getLeftRightKey } from './utils';
66
import type {
77
CellClipboardEvent,
88
CellCopyEvent,
@@ -71,9 +71,7 @@ export function TreeDataGrid<R, SR = unknown, K extends Key = Key>({
7171
const defaultRenderers = useDefaultRenderers<R, SR>();
7272
const rawRenderRow = renderers?.renderRow ?? defaultRenderers?.renderRow ?? defaultRenderRow;
7373
const headerAndTopSummaryRowsCount = 1 + (props.topSummaryRows?.length ?? 0);
74-
const isRtl = props.direction === 'rtl';
75-
const leftKey = isRtl ? 'ArrowRight' : 'ArrowLeft';
76-
const rightKey = isRtl ? 'ArrowLeft' : 'ArrowRight';
74+
const { leftKey, rightKey } = getLeftRightKey(props.direction);
7775
const toggleGroupLatest = useLatestFunc(toggleGroup);
7876

7977
const { columns, groupBy } = useMemo(() => {
@@ -310,7 +308,8 @@ export function TreeDataGrid<R, SR = unknown, K extends Key = Key>({
310308
// Expand the current group row if it is focused and is in collapsed state
311309
(event.key === rightKey && !row.isExpanded))
312310
) {
313-
event.preventDefault(); // Prevents scrolling
311+
// prevent scrolling
312+
event.preventDefault();
314313
event.preventGridDefault();
315314
toggleGroup(row.id);
316315
}

src/utils/keyboardUtils.ts

+11
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { Direction, Maybe } from '../types';
2+
13
// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values
24
const nonInputKeys = new Set([
35
// Special keys
@@ -85,3 +87,12 @@ export function onEditorNavigation({ key, target }: React.KeyboardEvent<HTMLDivE
8587
}
8688
return false;
8789
}
90+
91+
export function getLeftRightKey(direction: Maybe<Direction>) {
92+
const isRtl = direction === 'rtl';
93+
94+
return {
95+
leftKey: isRtl ? 'ArrowRight' : 'ArrowLeft',
96+
rightKey: isRtl ? 'ArrowLeft' : 'ArrowRight'
97+
} as const;
98+
}

test/browser/column/resizable.test.tsx

+46-3
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ const columns: readonly Column<Row>[] = [
5757
}
5858
];
5959

60-
test('cannot not resize or auto resize column when resizable is not specified', () => {
60+
test('cannot resize or auto resize column when resizable is not specified', () => {
6161
setup<Row, unknown>({ columns, rows: [] });
6262
const [col1] = getHeaderCells();
6363
expect(queryResizeHandle(col1)).not.toBeInTheDocument();
@@ -75,7 +75,7 @@ test('should resize column when dragging the handle', async () => {
7575
expect(onColumnResize).toHaveBeenCalledExactlyOnceWith(expect.objectContaining(columns[1]), 150);
7676
});
7777

78-
test('should use the maxWidth if specified', async () => {
78+
test('should use the maxWidth if specified when dragging the handle', async () => {
7979
setup<Row, unknown>({ columns, rows: [] });
8080
const grid = getGrid();
8181
await expect.element(grid).toHaveStyle({ gridTemplateColumns: '100px 200px ' });
@@ -84,7 +84,7 @@ test('should use the maxWidth if specified', async () => {
8484
await expect.element(grid).toHaveStyle({ gridTemplateColumns: '100px 400px' });
8585
});
8686

87-
test('should use the minWidth if specified', async () => {
87+
test('should use the minWidth if specified when dragging the handle', async () => {
8888
setup<Row, unknown>({ columns, rows: [] });
8989
const grid = getGrid();
9090
await expect.element(grid).toHaveStyle({ gridTemplateColumns: '100px 200px' });
@@ -93,6 +93,49 @@ test('should use the minWidth if specified', async () => {
9393
await expect.element(grid).toHaveStyle({ gridTemplateColumns: '100px 100px' });
9494
});
9595

96+
test('should resize column using keboard', async () => {
97+
const onColumnResize = vi.fn();
98+
setup<Row, unknown>({ columns, rows: [], onColumnResize });
99+
const grid = getGrid();
100+
expect(onColumnResize).not.toHaveBeenCalled();
101+
await expect.element(grid).toHaveStyle({ gridTemplateColumns: '100px 200px' });
102+
const [, col2] = getHeaderCells();
103+
await userEvent.click(col2);
104+
105+
await userEvent.keyboard('{Control>}{ArrowRight}{/Control}');
106+
await expect.element(grid).toHaveStyle({ gridTemplateColumns: '100px 210px' });
107+
expect(onColumnResize).toHaveBeenCalledWith(expect.objectContaining(columns[1]), 210);
108+
109+
await userEvent.keyboard('{Control>}{ArrowLeft}{/Control}');
110+
await expect.element(grid).toHaveStyle({ gridTemplateColumns: '100px 200px' });
111+
expect(onColumnResize).toHaveBeenCalledWith(expect.objectContaining(columns[1]), 200);
112+
expect(onColumnResize).toHaveBeenCalledTimes(2);
113+
});
114+
115+
test('should use the maxWidth if specified when resizing using keyboard', async () => {
116+
const onColumnResize = vi.fn();
117+
setup<Row, unknown>({ columns, rows: [], onColumnResize });
118+
const grid = getGrid();
119+
await expect.element(grid).toHaveStyle({ gridTemplateColumns: '100px 200px ' });
120+
const [, col2] = getHeaderCells();
121+
await userEvent.click(col2);
122+
await userEvent.keyboard(`{Control>}${'{ArrowRight}'.repeat(22)}{/Control}`);
123+
await expect.element(grid).toHaveStyle({ gridTemplateColumns: '100px 400px' });
124+
expect(onColumnResize).toHaveBeenCalledTimes(20);
125+
});
126+
127+
test('should use the minWidth if specified resizing using keyboard', async () => {
128+
const onColumnResize = vi.fn();
129+
setup<Row, unknown>({ columns, rows: [], onColumnResize });
130+
const grid = getGrid();
131+
await expect.element(grid).toHaveStyle({ gridTemplateColumns: '100px 200px' });
132+
const [, col2] = getHeaderCells();
133+
await userEvent.click(col2);
134+
await userEvent.keyboard(`{Control>}${'{ArrowLeft}'.repeat(12)}{/Control}`);
135+
await expect.element(grid).toHaveStyle({ gridTemplateColumns: '100px 100px' });
136+
expect(onColumnResize).toHaveBeenCalledTimes(10);
137+
});
138+
96139
test('should auto resize column when resize handle is double clicked', async () => {
97140
const onColumnResize = vi.fn();
98141
setup<Row, unknown>({

website/components/CellExpanderFormatter.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export function CellExpanderFormatter({
2020
}: CellExpanderFormatterProps) {
2121
function handleKeyDown(e: React.KeyboardEvent<HTMLSpanElement>) {
2222
if (e.key === ' ' || e.key === 'Enter') {
23+
// prevent scrolling
2324
e.preventDefault();
2425
onCellExpand();
2526
}

website/components/ChildRowDeleteButton.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export function ChildRowDeleteButton({
5151
}: ChildRowDeleteButtonProps) {
5252
function handleKeyDown(e: React.KeyboardEvent<HTMLSpanElement>) {
5353
if (e.key === 'Enter') {
54+
// prevent scrolling
5455
e.preventDefault();
5556
onDeleteSubRow();
5657
}

website/routes/ContextMenu.tsx

+4-6
Original file line numberDiff line numberDiff line change
@@ -118,12 +118,10 @@ function ContextMenuDemo() {
118118
<menu
119119
ref={menuRef}
120120
className={contextMenuClassname}
121-
style={
122-
{
123-
top: contextMenuProps.top,
124-
left: contextMenuProps.left
125-
} as unknown as React.CSSProperties
126-
}
121+
style={{
122+
top: contextMenuProps.top,
123+
left: contextMenuProps.left
124+
}}
127125
>
128126
<li>
129127
<button

0 commit comments

Comments
 (0)