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 (role-description and live region) #1557

Closed
wants to merge 6 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
16 changes: 7 additions & 9 deletions src/table/__integ__/resizable-columns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,11 @@
}

async assertColumnWidth(columnIndex: number, expected: number) {
await this.browser.waitUntil(async () => (await this.getColumnWidth(columnIndex)) === expected, {

Check warning on line 105 in src/table/__integ__/resizable-columns.test.ts

View workflow job for this annotation

GitHub Actions / dry-run / Components integration tests

RETRY 1: should resize column to grow by keyboard

Column at index "1" should have width "220". Observed width is "210". at node_modules/webdriverio/build/commands/browser/waitUntil.js:66:23 at Browser.wrapCommandFn (node_modules/@wdio/utils/build/shim.js:137:29) at TablePage.assertColumnWidth (src/table/__integ__/resizable-columns.test.ts:105:5) at src/table/__integ__/resizable-columns.test.ts:274:5 at src/table/__integ__/resizable-columns.test.ts:119:5 at Object.<anonymous> (node_modules/@cloudscape-design/browser-test-tools/dist/use-browser.js:28:13)
timeout: 1000,
timeoutMsg: `Column at index "${columnIndex}" should have width "${expected}"`,
timeoutMsg: `Column at index "${columnIndex}" should have width "${expected}". Observed width is "${await this.getColumnWidth(
columnIndex
)}".`,
});
}
}
Expand Down Expand Up @@ -268,16 +270,12 @@
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.assertColumnWidth(1, originalWidth + 10);
await expect(page.readTableWidth(1)).resolves.toEqual(originalWidth + 10);
await page.keys(['Enter', 'ArrowRight', 'Escape']);
await page.assertColumnWidth(1, originalWidth + 20);
await expect(page.readTableWidth(1)).resolves.toEqual(originalWidth + 20);
})
);
66 changes: 56 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,71 @@ 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.click();

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

test('activates and commits resize with resizer click', () => {
const onChange = jest.fn();
const { wrapper } = renderTable(<Table {...defaultProps} onColumnWidthsChange={event => onChange(event.detail)} />);
const columnResizerWrapper = wrapper.findColumnResizer(1)!;

columnResizerWrapper.focus();
columnResizerWrapper.click();
columnResizerWrapper.keydown(KeyCode.left);
expect(columnResizerWrapper.getElement()).toHaveTextContent('140');

columnResizerWrapper.click();

expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith({ widths: [140, 300] });
});

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

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

columnResizerWrapper.keydown(KeyCode.right);
expect(columnResizerWrapper.getElement()).toHaveTextContent('160');

columnResizerWrapper.keydown(KeyCode.escape);
expect(columnResizerWrapper.getElement()).toHaveTextContent('150');

columnResizerWrapper.click();

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.click();

columnResizerWrapper.keydown(KeyCode.right);
expect(columnResizerWrapper.getElement()).toHaveTextContent('160');

wrapper.findColumnResizer(2)!.focus();
expect(columnResizerWrapper.getElement()).toHaveTextContent('150');

columnResizerWrapper.focus();
columnResizerWrapper.click();

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

Expand Down
146 changes: 85 additions & 61 deletions src/table/resizer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import styles from './styles.css.js';
import { KeyCode } from '../../internal/keycode';
import { DEFAULT_COLUMN_WIDTH } from '../use-column-widths';
import { useStableCallback } from '@cloudscape-design/component-toolkit/internal';
import LiveRegion from '../../internal/components/live-region';

interface ResizerProps {
onDragMove: (newWidth: number) => void;
Expand Down Expand Up @@ -37,13 +38,15 @@ export function Resizer({
onFocus,
onBlur,
}: ResizerProps) {
const resizerRef = useRef<HTMLButtonElement>(null);
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);
const onDragStable = useStableCallback(onDragMove);
const [resizerHasFocus, setResizerHasFocus] = useState(false);
const [headerCellWidth, setHeaderCellWidth] = useState(0);
const [liveAnnouncement, setLiveAnnouncement] = useState('');
const originalHeaderCellWidthRef = useRef(0);

const handlers = useMemo(() => {
Expand All @@ -65,13 +68,20 @@ export function Resizer({
trackerElement.style.left = newOffset - scrollParentLeft - 1 + 'px';
};

const announceColumnWidth = (newWidth?: number) => {
const announcedWidth = newWidth ?? headerCell.getBoundingClientRect().width;
setLiveAnnouncement(announcedWidth.toFixed(0));
};

const updateColumnWidth = (newWidth: number) => {
const { right, width } = headerCell.getBoundingClientRect();
const updatedWidth = newWidth < minWidth ? minWidth : newWidth;
updateTrackerPosition(right + updatedWidth - width);
setHeaderCellWidth(newWidth);
// callbacks must be the last calls in the handler, because they may cause an extra update
onDragStable(newWidth);
if (isKeyboardDragging) {
announceColumnWidth(newWidth);
}
};

const resetColumnWidth = () => {
Expand Down Expand Up @@ -116,17 +126,27 @@ export function Resizer({
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 (event.keyCode === KeyCode.escape) {
setIsKeyboardDragging(false);
resetColumnWidth();
}
};

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

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

Expand All @@ -156,62 +178,64 @@ export function Resizer({
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);
useEffect(() => {
if (resizerRef.current) {
const headerCell = findUpUntil(resizerRef.current, element => element.tagName.toLowerCase() === 'th')!;
setHeaderCellWidth(headerCell.getBoundingClientRect().width);
}
}, []);
}, [headerCell, isDragging, isKeyboardDragging, resizerHasFocus, handlers]);

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={resizerRef}
aria-labelledby={ariaLabelledby}
aria-roledescription="Resize handle"
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);
}}
onClick={() => {
// Prevents mousemove handler from interfering when activated with VO+Space.
setIsDragging(false);

// Start resize
if (!isKeyboardDragging) {
setIsKeyboardDragging(true);
handlers?.announceColumnWidth();
}
// Commit resize
else {
setIsKeyboardDragging(false);
handlers?.announceColumnWidth();
onFinishStable();
}
}}
onFocus={event => {
const headerCell = findUpUntil(event.currentTarget, element => element.tagName.toLowerCase() === 'th')!;
setResizerHasFocus(true);
setHeaderCell(headerCell);
onFocus?.();
}}
onBlur={() => {
setResizerHasFocus(false);
onBlur?.();

// Discard keyboard resize if active
if (isKeyboardDragging) {
setIsKeyboardDragging(false);
handlers?.resetColumnWidth();
}
}}
tabIndex={tabIndex}
data-focus-id={focusId}
>
<LiveRegion assertive={true}>{liveAnnouncement}</LiveRegion>
</button>
);
}

Expand Down
9 changes: 9 additions & 0 deletions src/table/resizer/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,21 @@ $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 +66,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