-
Notifications
You must be signed in to change notification settings - Fork 156
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor: Table selection reduce coupling with table (#1496)
- Loading branch information
Showing
16 changed files
with
335 additions
and
102 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}) | ||
); | ||
}); |
4 changes: 2 additions & 2 deletions
4
src/table/__tests__/use-selection.test.tsx → ...election/__tests__/use-selection.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
File renamed without changes.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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')!; | ||
} |
Oops, something went wrong.