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

feat: Accessible resize handles with keyboard activation (separator toggle) #1561

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions src/table/__integ__/resizable-columns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,15 +268,15 @@ test(
await page.keys(['Tab']);
// wait for the resizer to attach handler

await page.keys(['ArrowRight']);
await page.assertColumnWidth(1, originalWidth + 10);
await expect(page.readTableWidth(1)).resolves.toEqual(originalWidth + 10);

await page.keys(['ArrowRight']);
await page.keys(['Enter', 'ArrowRight', 'ArrowRight', 'Enter']);
await page.assertColumnWidth(1, originalWidth + 20);
await expect(page.readTableWidth(1)).resolves.toEqual(originalWidth + 20);

await page.keys(['ArrowLeft']);
await page.keys(['Space', 'ArrowLeft', 'Space']);
await page.assertColumnWidth(1, originalWidth + 10);
await expect(page.readTableWidth(1)).resolves.toEqual(originalWidth + 10);

await page.keys(['Enter', 'ArrowRight', 'Escape']);
await page.assertColumnWidth(1, originalWidth + 10);
await expect(page.readTableWidth(1)).resolves.toEqual(originalWidth + 10);
})
Expand Down
54 changes: 44 additions & 10 deletions src/table/__tests__/resizable-columns.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import * as React from 'react';
import times from 'lodash/times';
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import createWrapper, { TableWrapper } from '../../../lib/components/test-utils/dom';
import Table, { TableProps } from '../../../lib/components/table';
import resizerStyles from '../../../lib/components/table/resizer/styles.css.js';
Expand Down Expand Up @@ -310,25 +310,59 @@ describe('resize with keyboard', () => {
HTMLElement.prototype.getBoundingClientRect = originalBoundingClientRect;
});

test('resizes columns with keyboard to the lect', async () => {
test('ignores arrow keys before entering the dragging mode', () => {
const onChange = jest.fn();
const { wrapper } = renderTable(<Table {...defaultProps} onColumnWidthsChange={event => onChange(event.detail)} />);
const columnResizerWrapper = wrapper.findColumnResizer(1)!;

columnResizerWrapper.focus();
columnResizerWrapper.keydown(KeyCode.right);
columnResizerWrapper.keydown(KeyCode.enter);

expect(onChange).toHaveBeenCalledTimes(0);
});

test.each([KeyCode.space, KeyCode.enter])('activates and commits resize with [%s] key code', keyCode => {
const onChange = jest.fn();
const { wrapper } = renderTable(<Table {...defaultProps} onColumnWidthsChange={event => onChange(event.detail)} />);
const columnResizerWrapper = wrapper.findColumnResizer(1)!;

columnResizerWrapper.focus();
columnResizerWrapper.keydown(keyCode);
columnResizerWrapper.keydown(KeyCode.left);
columnResizerWrapper.keydown(keyCode);

await waitFor(() => {
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith({ widths: [150 - 10, 300] });
});
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith({ widths: [140, 300] });
});

test.each([KeyCode.escape])('discards resize with [%s] key code', keyCode => {
const onChange = jest.fn();
const { wrapper } = renderTable(<Table {...defaultProps} onColumnWidthsChange={event => onChange(event.detail)} />);
const columnResizerWrapper = wrapper.findColumnResizer(1)!;

columnResizerWrapper.focus();
columnResizerWrapper.keydown(KeyCode.enter);
columnResizerWrapper.keydown(KeyCode.right);
columnResizerWrapper.keydown(keyCode);
columnResizerWrapper.keydown(KeyCode.enter);

expect(onChange).toHaveBeenCalledTimes(0);
});

test('discards resize on blur', () => {
const onChange = jest.fn();
const { wrapper } = renderTable(<Table {...defaultProps} onColumnWidthsChange={event => onChange(event.detail)} />);
const columnResizerWrapper = wrapper.findColumnResizer(1)!;

columnResizerWrapper.focus();
columnResizerWrapper.keydown(KeyCode.enter);
columnResizerWrapper.keydown(KeyCode.right);
wrapper.findColumnResizer(2)!.focus();
columnResizerWrapper.focus();
columnResizerWrapper.keydown(KeyCode.enter);

await waitFor(() => {
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenCalledWith({ widths: [150 + 10, 300] });
});
expect(onChange).toHaveBeenCalledTimes(0);
});
});

Expand Down
2 changes: 0 additions & 2 deletions src/table/header-cell/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ interface TableHeaderCellProps<ItemType> {
onResizeFinish: () => void;
colIndex: number;
updateColumn: (columnId: PropertyKey, newWidth: number) => void;
onFocus?: () => void;
onBlur?: () => void;
resizableColumns?: boolean;
isEditable?: boolean;
columnId: PropertyKey;
Expand Down
151 changes: 86 additions & 65 deletions src/table/resizer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@
tabIndex?: number;
focusId?: string;
showFocusRing?: boolean;
onFocus?: () => void;
onBlur?: () => void;
}

const AUTO_GROW_START_TIME = 10;
Expand All @@ -34,10 +32,9 @@
tabIndex,
showFocusRing,
focusId,
onFocus,
onBlur,
}: ResizerProps) {
const [isDragging, setIsDragging] = useState(false);
const [isKeyboardDragging, setIsKeyboardDragging] = useState(false);
const [headerCell, setHeaderCell] = useState<null | HTMLElement>(null);
const autoGrowTimeout = useRef<ReturnType<typeof setTimeout> | undefined>();
const onFinishStable = useStableCallback(onFinish);
Expand Down Expand Up @@ -113,20 +110,40 @@
};

const onKeyDown = (event: KeyboardEvent) => {
if (event.keyCode === KeyCode.left) {
event.preventDefault();
updateColumnWidth(headerCell.getBoundingClientRect().width - 10);
setTimeout(() => onFinishStable(), 0);
}
if (event.keyCode === KeyCode.right) {
event.preventDefault();
updateColumnWidth(headerCell.getBoundingClientRect().width + 10);
setTimeout(() => onFinishStable(), 0);
if (isKeyboardDragging) {
// Update width
if (event.keyCode === KeyCode.left) {
event.preventDefault();
updateColumnWidth(headerCell.getBoundingClientRect().width - 10);
}
if (event.keyCode === KeyCode.right) {
event.preventDefault();
updateColumnWidth(headerCell.getBoundingClientRect().width + 10);
}
// Exit keyboard dragging mode
if (event.keyCode === KeyCode.enter || event.keyCode === KeyCode.space) {
event.preventDefault();
setIsKeyboardDragging(false);
onFinishStable();
resizerToggleRef.current?.focus();
}
if (event.keyCode === KeyCode.escape) {
event.preventDefault();
setIsKeyboardDragging(false);
resetColumnWidth();
resizerToggleRef.current?.focus();
}
} else {
// Enter keyboard dragging mode
if (event.keyCode === KeyCode.enter || event.keyCode === KeyCode.space) {
event.preventDefault();
setIsKeyboardDragging(true);
}
}
};

return { updateTrackerPosition, updateColumnWidth, resetColumnWidth, onMouseMove, onMouseUp, onKeyDown };
}, [headerCell, minWidth, onDragStable, onFinishStable]);
}, [headerCell, isKeyboardDragging, minWidth, onDragStable, onFinishStable]);

useEffect(() => {
if ((!isDragging && !resizerHasFocus) || !headerCell || !handlers) {
Expand All @@ -143,10 +160,12 @@
document.addEventListener('mouseup', handlers.onMouseUp);
}
if (resizerHasFocus) {
document.body.classList.add(styles['resize-active']);
document.body.classList.add(styles['resize-active-with-focus']);
headerCell.addEventListener('keydown', handlers.onKeyDown);
}
if (isKeyboardDragging) {
document.body.classList.add(styles['resize-active']);
}

return () => {
clearTimeout(autoGrowTimeout.current);
Expand All @@ -156,62 +175,64 @@
document.removeEventListener('mouseup', handlers.onMouseUp);
headerCell.removeEventListener('keydown', handlers.onKeyDown);
};
}, [headerCell, isDragging, onFinishStable, resizerHasFocus, handlers]);

const headerCellWidthString = headerCellWidth.toFixed(0);
const resizerAriaProps = {
role: 'separator',
'aria-labelledby': ariaLabelledby,
'aria-orientation': 'vertical' as const,
'aria-valuenow': headerCellWidth,
// aria-valuetext is needed because the VO announces "collapsed" when only aria-valuenow set without aria-valuemax
'aria-valuetext': headerCellWidthString,
'aria-valuemin': minWidth,
};

// Read header width after mounting for it to be available in the element's ARIA label before it gets focused.
const resizerRef = useRef<HTMLSpanElement>(null);
}, [headerCell, isDragging, isKeyboardDragging, onFinishStable, resizerHasFocus, handlers]);

const resizerToggleRef = useRef<HTMLButtonElement>(null);

// Read header width and text content after mounting for it to be available in the element's ARIA label before it gets focused.
useEffect(() => {
if (resizerRef.current) {
const headerCell = findUpUntil(resizerRef.current, element => element.tagName.toLowerCase() === 'th')!;
if (resizerToggleRef.current) {
const headerCell = findUpUntil(resizerToggleRef.current, element => element.tagName.toLowerCase() === 'th')!;
setHeaderCellWidth(headerCell.getBoundingClientRect().width);
}
}, []);

return (
<>
<span
ref={resizerRef}
className={clsx(
styles.resizer,
isDragging && styles['resizer-active'],
(resizerHasFocus || showFocusRing) && styles['has-focus']
)}
onMouseDown={event => {
if (event.button !== 0) {
return;
}
event.preventDefault();
const headerCell = findUpUntil(event.currentTarget, element => element.tagName.toLowerCase() === 'th')!;
setIsDragging(true);
setHeaderCell(headerCell);
}}
onFocus={event => {
const headerCell = findUpUntil(event.currentTarget, element => element.tagName.toLowerCase() === 'th')!;
setHeaderCellWidth(headerCell.getBoundingClientRect().width);
setResizerHasFocus(true);
setHeaderCell(headerCell);
onFocus?.();
}}
onBlur={() => {
setResizerHasFocus(false);
onBlur?.();
}}
{...resizerAriaProps}
tabIndex={tabIndex}
data-focus-id={focusId}
/>
</>
<button
ref={resizerToggleRef}
className={clsx(
styles.resizer,
isDragging && styles['resizer-active'],
(resizerHasFocus || showFocusRing || isKeyboardDragging) && styles['has-focus']
)}
onMouseDown={event => {
if (event.button !== 0) {
return;
}
event.preventDefault();
const headerCell = findUpUntil(event.currentTarget, element => element.tagName.toLowerCase() === 'th')!;
setIsDragging(true);
setHeaderCell(headerCell);
}}
onClick={() => {

Check warning on line 207 in src/table/resizer/index.tsx

View check run for this annotation

Codecov / codecov/patch

src/table/resizer/index.tsx#L207

Added line #L207 was not covered by tests
// Prevent mouse drag activation and activate keyboard dragging for VO+Space click.
setIsDragging(false);
setResizerHasFocus(true);
setIsKeyboardDragging(true);

Check warning on line 211 in src/table/resizer/index.tsx

View check run for this annotation

Codecov / codecov/patch

src/table/resizer/index.tsx#L209-L211

Added lines #L209 - L211 were not covered by tests
}}
onFocus={event => {
const headerCell = findUpUntil(event.currentTarget, element => element.tagName.toLowerCase() === 'th')!;
setHeaderCellWidth(headerCell.getBoundingClientRect().width);
setResizerHasFocus(true);
setHeaderCell(headerCell);
}}
onBlur={() => {
setResizerHasFocus(false);
if (isKeyboardDragging) {
setIsKeyboardDragging(false);
handlers?.resetColumnWidth();
}
}}
role="separator"
aria-roledescription={isKeyboardDragging ? 'resize handle, active' : 'resize handle, inactive'}
aria-orientation="vertical"
aria-valuenow={Math.round(headerCellWidth)}
aria-valuetext={headerCellWidth.toFixed(0)}
aria-valuemin={minWidth}
aria-labelledby={ariaLabelledby}
tabIndex={tabIndex}
data-focus-id={focusId}
/>
);
}

Expand Down
8 changes: 8 additions & 0 deletions src/table/resizer/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,20 @@ $handle-width: awsui.$space-l;
$active-separator-width: 2px;

.resizer {
border: none;
background: none;

bottom: 0;
cursor: col-resize;
position: absolute;
right: calc(-1 * #{$handle-width} / 2);
top: 0;
width: $handle-width;
z-index: 10;
&:focus {
outline: none;
text-decoration: none;
}
//stylelint-disable-next-line selector-combinator-disallowed-list
.resize-active & {
pointer-events: none;
Expand Down Expand Up @@ -58,6 +65,7 @@ $active-separator-width: 2px;
box-shadow: inset 0 0 0 2px awsui.$color-border-item-focused;
}
}
@include styles.styles-reset;
}

.tracker {
Expand Down
Loading