Skip to content

Commit

Permalink
refactor: Table selection reduce coupling with table (#1496)
Browse files Browse the repository at this point in the history
  • Loading branch information
pan-kot authored Sep 4, 2023
1 parent 520eee8 commit df04533
Show file tree
Hide file tree
Showing 16 changed files with 335 additions and 102 deletions.
135 changes: 135 additions & 0 deletions pages/table-fragments/selection-custom.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useContext, useState } from 'react';
import { ColumnLayout, Container, ContentLayout, FormField, Header, Link, Select } from '~components';
import { SelectionControl, focusMarkers, useSelectionFocusMove, useSelection } from '~components/table/selection';
import styles from './styles.scss';
import { generateItems, Instance } from '../table/generate-data';
import AppContext, { AppContextType } from '../app/app-context';
import ScreenreaderOnly from '~components/internal/components/screenreader-only';
import clsx from 'clsx';

type PageContext = React.Context<
AppContextType<{
selectionType: 'single' | 'multi';
}>
>;

const items = generateItems(25);

const selectionTypeOptions = [{ value: 'single' }, { value: 'multi' }];

export default function Page() {
const { urlParams, setUrlParams } = useContext(AppContext as PageContext);
const selectionType = urlParams.selectionType ?? 'single';

const [selectedItems, setSelectedItems] = useState<Instance[]>([]);
const { getSelectAllProps, getItemSelectionProps, updateShiftToggle } = useSelection({
items,
selectedItems,
selectionType,
onSelectionChange: event => setSelectedItems(event.detail.selectedItems),
ariaLabels: {
selectionGroupLabel: 'group label',
allItemsSelectionLabel: ({ selectedItems }) => `${selectedItems.length} item selected`,
itemSelectionLabel: ({ selectedItems }, item) =>
`${item.id} is ${selectedItems.indexOf(item) < 0 ? 'not ' : ''}selected`,
},
});

const { moveFocusDown, moveFocusUp, moveFocus } = useSelectionFocusMove(selectionType, items.length);

const columnDefinitions = [
{
key: 'selection',
header:
selectionType === 'multi' ? (
<SelectionControl
onFocusDown={event => moveFocus?.(event.target as HTMLElement, -1, +1)}
{...getSelectAllProps()}
/>
) : (
<ScreenreaderOnly>selection cell</ScreenreaderOnly>
),
cell: (item: Instance) => (
<SelectionControl
onFocusDown={moveFocusDown}
onFocusUp={moveFocusUp}
onShiftToggle={updateShiftToggle}
{...getItemSelectionProps(item)}
/>
),
},
{
key: 'id',
header: 'ID',
cell: (item: Instance) => item.id,
},
{
key: 'imageId',
header: 'Image ID',
cell: (item: Instance) => <Link>{item.imageId}</Link>,
},
{
key: 'state',
header: 'State',
cell: (item: Instance) => item.state,
},
{ key: 'dnsName', header: 'DNS name', cell: (item: Instance) => item.dnsName ?? '?' },
{ key: 'type', header: 'Type', cell: (item: Instance) => item.type },
];

return (
<ContentLayout header={<Header variant="h1">Rows selection with a custom table</Header>}>
<Container
disableContentPaddings={true}
header={
<ColumnLayout columns={3}>
<FormField label="Table role">
<Select
data-testid="selection-type"
options={selectionTypeOptions}
selectedOption={selectionTypeOptions.find(option => option.value === selectionType) ?? null}
onChange={event =>
setUrlParams({ selectionType: event.detail.selectedOption.value as 'single' | 'multi' })
}
/>
</FormField>
</ColumnLayout>
}
{...focusMarkers.root}
>
<div className={styles['custom-table']}>
<table className={styles['custom-table-table']} role="grid">
<thead>
<tr {...focusMarkers.all}>
{columnDefinitions.map(column => (
<th
key={column.key}
className={clsx(
styles['custom-table-cell'],
column.key === 'selection' && styles['custom-table-selection-cell']
)}
>
{column.header}
</th>
))}
</tr>
</thead>
<tbody>
{items.map((item, index) => (
<tr key={item.id} {...focusMarkers.item} data-rowindex={index + 1}>
{columnDefinitions.map(column => (
<td key={column.key} className={styles['custom-table-cell']}>
{column.cell(item)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Container>
</ContentLayout>
);
}
5 changes: 5 additions & 0 deletions pages/table-fragments/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
}

&-cell {
position: relative;
border-left: 1px solid tokens.$color-border-divider-default;
border-bottom: 1px solid tokens.$color-border-divider-default;
padding: 8px 16px;
Expand Down Expand Up @@ -59,6 +60,10 @@
}
}

&-selection-cell {
width: 40px;
}

&-sorting-header {
padding: 0;
margin: 0;
Expand Down
11 changes: 8 additions & 3 deletions src/cards/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@ import { getCardsPerRow } from './cards-layout-helper';
import { getBaseProps } from '../internal/base-component';
import ToolsHeader from '../table/tools-header';
import { getItemKey } from '../table/utils';
import { focusMarkers, useFocusMove, useSelection } from '../table/use-selection';
import SelectionControl, { SelectionControlProps } from '../table/selection-control';
import {
SelectionControl,
SelectionControlProps,
focusMarkers,
useSelectionFocusMove,
useSelection,
} from '../table/selection';
import InternalContainer from '../container/internal';
import InternalStatusIndicator from '../status-indicator/internal';
import { applyDisplayName } from '../internal/utils/apply-display-name';
Expand Down Expand Up @@ -219,7 +224,7 @@ const CardsList = <T,>({
}) => {
const selectable = !!selectionType;

const { moveFocusDown, moveFocusUp } = useFocusMove(selectionType, items.length);
const { moveFocusDown, moveFocusUp } = useSelectionFocusMove(selectionType, items.length);

let visibleSectionsDefinition = cardDefinition.sections || [];
visibleSectionsDefinition = visibleSections
Expand Down
2 changes: 1 addition & 1 deletion src/table/__integ__/sticky-header.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import useBrowser from '@cloudscape-design/browser-test-tools/use-browser';
import createWrapper from '../../../lib/components/test-utils/selectors';
import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects';
import styles from '../../../lib/components/table/styles.selectors.js';
import selectionStyles from '../../../lib/components/table/selection-control/styles.selectors.js';
import selectionStyles from '../../../lib/components/table/selection/styles.selectors.js';

const tableWrapper = createWrapper().findTable();
const tableScrollWrapper = tableWrapper.findByClassName(styles.wrapper);
Expand Down
5 changes: 2 additions & 3 deletions src/table/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@ import Thead, { TheadProps } from './thead';
import { TableBodyCell } from './body-cell';
import InternalStatusIndicator from '../status-indicator/internal';
import { supportsStickyPosition } from '../internal/utils/dom';
import SelectionControl from './selection-control';
import { checkSortingState, getColumnKey, getItemKey, getVisibleColumnDefinitions, toContainerVariant } from './utils';
import { useRowEvents } from './use-row-events';
import { focusMarkers, useFocusMove, useSelection } from './use-selection';
import { SelectionControl, focusMarkers, useSelectionFocusMove, useSelection } from './selection';
import { fireNonCancelableEvent } from '../internal/events';
import { isDevelopment } from '../internal/is-development';
import { ColumnWidthDefinition, ColumnWidthsProvider, DEFAULT_COLUMN_WIDTH } from './use-column-widths';
Expand Down Expand Up @@ -120,7 +119,7 @@ const InternalTable = React.forwardRef(

const handleScroll = useScrollSync([wrapperRefObject, scrollbarRef, secondaryWrapperRef]);

const { moveFocusDown, moveFocusUp, moveFocus } = useFocusMove(selectionType, items.length);
const { moveFocusDown, moveFocusUp, moveFocus } = useSelectionFocusMove(selectionType, items.length);
const { onRowClickHandler, onRowContextMenuHandler } = useRowEvents({ onRowClick, onRowContextMenu });

const visibleColumnDefinitions = getVisibleColumnDefinitions({
Expand Down
66 changes: 66 additions & 0 deletions src/table/selection/__integ__/selection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects';
import useBrowser from '@cloudscape-design/browser-test-tools/use-browser';

class SelectionTestPage extends BasePageObject {
isChecked(selector: string) {
return this.browser.execute(selector => (document.querySelector(selector) as HTMLInputElement).checked, selector);
}
}

const setupTest = (selectionType: 'single' | 'multi', testFn: (page: SelectionTestPage) => Promise<void>) => {
return useBrowser(async browser => {
const page = new SelectionTestPage(browser);
await browser.url(`#/light/table-fragments/selection-custom?selectionType=${selectionType}`);
await page.waitForVisible('table');
await testFn(page);
});
};

describe('selection', () => {
test(
'selects first item with single selection',
setupTest('single', async page => {
await page.click('[data-testid="selection-type"]');
await page.keys(['Tab', 'Space']);
await expect(page.isChecked('tr[data-rowindex="1"] input')).resolves.toBe(true);
await expect(page.isChecked('tr[data-rowindex="2"] input')).resolves.toBe(false);
})
);

test(
'selects second item with single selection',
setupTest('single', async page => {
await page.click('[data-testid="selection-type"]');
await page.keys(['Tab', 'ArrowDown']);
await expect(page.isChecked('tr[data-rowindex="1"] input')).resolves.toBe(false);
await expect(page.isChecked('tr[data-rowindex="2"] input')).resolves.toBe(true);
})
);

test(
'selects first two items with multi selection',
setupTest('multi', async page => {
await page.click('[data-testid="selection-type"]');
await page.keys(['Tab', 'ArrowDown', 'Space']);
await page.keys(['ArrowDown', 'Space']);
await expect(page.isChecked('tr[data-rowindex="1"] input')).resolves.toBe(true);
await expect(page.isChecked('tr[data-rowindex="2"] input')).resolves.toBe(true);
await expect(page.isChecked('tr[data-rowindex="3"] input')).resolves.toBe(false);
})
);

test(
'selects all items but first with multi selection',
setupTest('multi', async page => {
await page.click('[data-testid="selection-type"]');
await page.keys(['Tab', 'Space']);
await page.keys(['ArrowDown', 'Space']);
await expect(page.isChecked('tr[data-rowindex="1"] input')).resolves.toBe(false);
await expect(page.isChecked('tr[data-rowindex="2"] input')).resolves.toBe(true);
await expect(page.isChecked('tr[data-rowindex="3"] input')).resolves.toBe(true);
})
);
});
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { useSelection } from '../../../lib/components/table/use-selection';
import { renderHook } from '../../__tests__/render-hook';
import { useSelection } from '../../../../lib/components/table/selection';
import { renderHook } from '../../../__tests__/render-hook';

describe('useSelection', () => {
it('satisfies istanbul coverage', () => {
Expand Down
8 changes: 8 additions & 0 deletions src/table/selection/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

export { SelectionProps } from './interfaces';
export { SelectionControl, SelectionControlProps } from './selection-control';
export { useSelectionFocusMove } from './use-selection-focus-move';
export { useSelection } from './use-selection';
export { focusMarkers } from './utils';
12 changes: 12 additions & 0 deletions src/table/selection/interfaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

export interface SelectionProps {
name: string;
disabled: boolean;
selectionType: 'single' | 'multi';
indeterminate?: boolean;
checked: boolean;
onChange: () => void;
ariaLabel?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import InternalCheckbox from '../../checkbox/internal';
import RadioButton from '../../radio-group/radio-button';

import styles from './styles.css.js';
import { SelectionProps } from '../use-selection';
import { SelectionProps } from './interfaces';

export interface SelectionControlProps extends SelectionProps {
onShiftToggle?(shiftPressed: boolean): void;
Expand All @@ -19,7 +19,7 @@ export interface SelectionControlProps extends SelectionProps {
focusedComponent?: null | string;
}

export default function SelectionControl({
export function SelectionControl({
selectionType,
indeterminate = false,
onShiftToggle,
Expand Down
File renamed without changes.
57 changes: 57 additions & 0 deletions src/table/selection/use-selection-focus-move.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { KeyboardEvent } from 'react';
import { findUpUntil } from '../../internal/utils/dom';
import { TableProps } from '../interfaces';
import selectionStyles from './styles.css.js';
import { SELECTION_ITEM } from './utils';

// The hooks moves focus between multi-selection checkboxes.
// Not eligible for tables with grid navigation.
export function useSelectionFocusMove(selectionType: TableProps['selectionType'], totalItems: number) {
if (selectionType !== 'multi') {
return {};
}
function moveFocus(sourceElement: HTMLElement, fromIndex: number, direction: -1 | 1) {
let index = fromIndex;
const rootContainer = findRootContainer(sourceElement);

while (index >= -1 && index < totalItems) {
index += direction;
const control = findSelectionControlByIndex(rootContainer, index);
if (control && !control.disabled) {
control.focus();
break;
}
}
}
const [moveFocusDown, moveFocusUp] = ([1, -1] as const).map(direction => {
return (event: KeyboardEvent) => {
const target = event.currentTarget as HTMLElement;
const itemNode = findUpUntil(target, node => node.dataset.selectionItem === 'item')!;
const fromIndex = Array.prototype.indexOf.call(itemNode.parentElement!.children, itemNode);
moveFocus(target, fromIndex, direction);
};
});
return {
moveFocusDown,
moveFocusUp,
moveFocus,
};
}

function findSelectionControlByIndex(rootContainer: HTMLElement, index: number) {
if (index === -1) {
// find "select all" checkbox
return rootContainer.querySelector<HTMLInputElement>(
`[data-${SELECTION_ITEM}="all"] .${selectionStyles.root} input`
);
}
return rootContainer.querySelectorAll<HTMLInputElement>(
`[data-${SELECTION_ITEM}="item"] .${selectionStyles.root} input`
)[index];
}

function findRootContainer(element: HTMLElement) {
return findUpUntil(element, node => node.dataset.selectionRoot === 'true')!;
}
Loading

0 comments on commit df04533

Please sign in to comment.