Skip to content

Commit

Permalink
chore: Table grid navigation (no integration) (#1389)
Browse files Browse the repository at this point in the history
  • Loading branch information
pan-kot authored Aug 17, 2023
1 parent 3e4fceb commit e87f4e7
Show file tree
Hide file tree
Showing 19 changed files with 1,424 additions and 32 deletions.
7 changes: 6 additions & 1 deletion pages/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,21 @@ import Header from './components/header';
import StrictModeWrapper from './components/strict-mode-wrapper';
import AppContext, { AppContextProvider, parseQuery } from './app-context';

function isAppLayoutPage(pageId?: string) {
const appLayoutPages = ['app-layout', 'content-layout', 'grid-navigation-custom'];
return pageId !== undefined && appLayoutPages.some(match => pageId.includes(match));
}

function App() {
const {
mode,
pageId,
urlParams: { density, motionDisabled },
} = useContext(AppContext);

const isAppLayout = pageId !== undefined && (pageId.includes('app-layout') || pageId.includes('content-layout'));
// AppLayout already contains <main>
// Also, AppLayout pages should resemble the ConsoleNav 2.0 styles
const isAppLayout = isAppLayoutPage(pageId);
const ContentTag = isAppLayout ? 'div' : 'main';
const isMacOS = navigator.userAgent.toLowerCase().indexOf('macintosh') > -1;

Expand Down
273 changes: 273 additions & 0 deletions pages/table-fragments/grid-navigation-custom.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useContext, useMemo, useRef, useState } from 'react';
import SpaceBetween from '~components/space-between';
import {
AppLayout,
Button,
ColumnLayout,
Container,
ContentLayout,
FormField,
Header,
HelpPanel,
Icon,
Input,
Link,
SegmentedControl,
} from '~components';
import styles from './styles.scss';
import { id as generateId, generateItems, Instance } from '../table/generate-data';
import AppContext, { AppContextType } from '../app/app-context';
import {
getTableCellRoleProps,
getTableColHeaderRoleProps,
getTableHeaderRowRoleProps,
getTableRoleProps,
getTableRowRoleProps,
getTableWrapperRoleProps,
useGridNavigation,
} from '~components/table/table-role';
import { orderBy } from 'lodash';
import appLayoutLabels from '../app-layout/utils/labels';

type PageContext = React.Context<
AppContextType<{
pageSize: number;
}>
>;

const createColumnDefinitions = ({
onDelete,
onDuplicate,
onUpdate,
}: {
onDelete: (id: string) => void;
onDuplicate: (id: string) => void;
onUpdate: (id: string) => void;
}) => [
{
key: 'id',
label: 'ID',
render: (item: Instance) => item.id,
},
{
key: 'actions',
label: 'Actions',
render: (item: Instance) => (
<div style={{ display: 'flex', gap: '8px', flexWrap: 'nowrap' }}>
<Button variant="inline-icon" iconName="remove" ariaLabel="Delete item" onClick={() => onDelete(item.id)} />
<Button variant="inline-icon" iconName="copy" ariaLabel="Duplicate item" onClick={() => onDuplicate(item.id)} />
<Button variant="inline-icon" iconName="refresh" ariaLabel="Update item" onClick={() => onUpdate(item.id)} />
</div>
),
},
{
key: 'state',
label: 'State',
render: (item: Instance) => item.state,
},
{
key: 'imageId',
label: 'Image ID',
render: (item: Instance) => <Link>{item.imageId}</Link>,
},
{
key: 'state-toggle',
label: 'State toggle',
render: (item: Instance) => (
<SegmentedControl
selectedId={item.state === 'RUNNING' || item.state === 'STOPPING' ? 'On' : 'Off'}
onChange={event => alert(`Changed item state to "${event.detail.selectedId}"`)}
label="Instance state"
options={[
{ text: 'On', id: 'On' },
{ text: 'Off', id: 'Off' },
]}
/>
),
isWidget: true,
},
{ key: 'dnsName', label: 'DNS name', render: (item: Instance) => item.dnsName ?? '?' },
{ key: 'dnsName2', label: 'DNS name 2', render: (item: Instance) => (item.dnsName ?? '?') + ':2' },
{ key: 'dnsName3', label: 'DNS name 3', render: (item: Instance) => (item.dnsName ?? '?') + ':3' },
{ key: 'type', label: 'Type', render: (item: Instance) => item.type },
];

export default function Page() {
const [toolsOpen, setToolsOpen] = useState(false);
const { urlParams, setUrlParams } = useContext(AppContext as PageContext);
const pageSize = urlParams.pageSize ?? 10;

const [items, setItems] = useState(generateItems(25));
const columnDefinitions = useMemo(
() =>
createColumnDefinitions({
onDelete: (id: string) => setItems(prev => prev.filter(item => item.id !== id)),
onDuplicate: (id: string) =>
setItems(prev => prev.flatMap(item => (item.id !== id ? [item] : [item, { ...item, id: generateId() }]))),
onUpdate: (id: string) =>
setItems(prev => prev.map(item => (item.id !== id ? item : { ...item, id: generateId() }))),
}),
[]
);

const [sortingKey, setSortingKey] = useState<null | string>(null);
const [sortingDirection, setSortingDirection] = useState<1 | -1>(1);

const tableRef = useRef<HTMLTableElement>(null);

const tableRole = 'grid';
useGridNavigation({ tableRole, pageSize, getTable: () => tableRef.current });

const sortedItems = useMemo(() => {
if (!sortingKey) {
return items;
}
return orderBy(items, [sortingKey], [sortingDirection === -1 ? 'desc' : 'asc']);
}, [items, sortingKey, sortingDirection]);

return (
<AppLayout
ariaLabels={appLayoutLabels}
contentType="table"
navigationHide={true}
toolsOpen={toolsOpen}
onToolsChange={event => setToolsOpen(event.detail.open)}
tools={<GridNavigationHelpPanel />}
content={
<ContentLayout header={<Header variant="h1">Grid navigation with a custom table grid</Header>}>
<Container
disableContentPaddings={true}
header={
<SpaceBetween size="m">
<ColumnLayout columns={3}>
<FormField label="Page size">
<Input
type="number"
value={pageSize.toString()}
onChange={event => setUrlParams({ pageSize: parseInt(event.detail.value) })}
/>
</FormField>
</ColumnLayout>

<Link onFollow={() => setToolsOpen(true)} data-testid="link-before">
How to use grid navigation?
</Link>
</SpaceBetween>
}
footer={
<Link onFollow={() => setToolsOpen(true)} data-testid="link-after">
How to use grid navigation?
</Link>
}
>
<div className={styles['custom-table']} {...getTableWrapperRoleProps({ tableRole, isScrollable: false })}>
<table
ref={tableRef}
className={styles['custom-table-table']}
{...getTableRoleProps({ tableRole, totalItemsCount: items.length })}
>
<thead>
<tr {...getTableHeaderRowRoleProps({ tableRole })}>
{columnDefinitions.map((column, colIndex) => (
<th
key={column.key}
className={styles['custom-table-cell']}
{...getTableColHeaderRoleProps({ tableRole, colIndex })}
>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'nowrap' }}>
<button
className={styles['custom-table-sorting-header']}
onClick={() => {
if (sortingKey !== column.key) {
setSortingKey(column.key);
setSortingDirection(-1);
} else {
setSortingDirection(prev => (prev === 1 ? -1 : 1));
}
}}
>
{column.label}
</button>
{sortingKey === column.key && sortingDirection === -1 && <Icon name="angle-down" />}
{sortingKey === column.key && sortingDirection === 1 && <Icon name="angle-up" />}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{sortedItems.map((item, rowIndex) => (
<tr key={item.id} {...getTableRowRoleProps({ tableRole, rowIndex, firstIndex: 0 })}>
{columnDefinitions.map((column, colIndex) => (
<td
key={column.key}
className={styles['custom-table-cell']}
{...getTableCellRoleProps({ tableRole, colIndex })}
data-widget-cell={column.isWidget}
>
{column.render(item)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Container>
</ContentLayout>
}
/>
);
}

function GridNavigationHelpPanel() {
return (
<HelpPanel header={<Header variant="h2">Grid navigation</Header>}>
<p>
Grid tables offer better efficient navigation for keyboard users. The navigation intercepts keyboard commands to
focus table cells and focusable cell content using arrow keys and other key combinations. Here is the full list
of commands to move item focus:
</p>
<ul>
<li>
<b>Arrow Up</b> (one item up)
</li>
<li>
<b>Arrow Down</b> (one item down)
</li>
<li>
<b>Arrow Left</b> (one item to the left)
</li>
<li>
<b>Arrow Right</b> (one item to the right)
</li>
<li>
<b>Page Up</b> (one page up)
</li>
<li>
<b>Page Down</b> (one page down)
</li>
<li>
<b>Home</b> (to the first item in the row)
</li>
<li>
<b>End</b> (to the last item in the row)
</li>
<li>
<b>Control+Home</b> (to the first item in the grid)
</li>
<li>
<b>Control+End</b> (to the last item in the grid)
</li>
<li>
<b>Enter</b> (to move focus inside widget cell)
</li>
<li>
<b>Escape</b> (to move widget focus back to cell)
</li>
</ul>
</HelpPanel>
);
}
9 changes: 9 additions & 0 deletions pages/table-fragments/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,13 @@
}
}
}

&-sorting-header {
padding: 0;
margin: 0;
background: none;
border: none;
font-weight: bold;
margin-left: 2px;
}
}
2 changes: 1 addition & 1 deletion pages/table/generate-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export interface Instance {
dnsName?: string;
}

function id() {
export function id() {
const id = Math.ceil(pseudoRandom() * Math.pow(16, 8)).toString(16);
return padStart(id, 8, '0');
}
Expand Down
1 change: 1 addition & 0 deletions pages/table/inline-editor.permutations.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export default function InlineEditorPermutations() {
onEditEnd={() => {}}
wrapLines={false}
columnId="id"
colIndex={0}
stickyState={stickyState}
tableRole="grid"
{...permutation}
Expand Down
2 changes: 2 additions & 0 deletions src/table/__tests__/body-cell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const TestComponent = ({ isEditing = false, successfulEdit = false }) => {
stickyState={result.current}
successfulEdit={successfulEdit}
columnId="id"
colIndex={0}
tableRole={tableRole}
/>
</tr>
Expand Down Expand Up @@ -94,6 +95,7 @@ const TestComponent2 = ({ column }: any) => {
wrapLines={false}
stickyState={result.current}
columnId="id"
colIndex={0}
tableRole={tableRole}
/>
</tr>
Expand Down
6 changes: 4 additions & 2 deletions src/table/body-cell/td-element.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import React from 'react';
import styles from './styles.css.js';
import { getStickyClassNames } from '../utils';
import { StickyColumnsModel, useStickyCellStyles } from '../sticky-columns';
import { TableRole, getTableCellRoleProps } from '../table-role/table-role-helper.js';
import { TableRole, getTableCellRoleProps } from '../table-role';

export interface TableTdElementProps {
className?: string;
Expand All @@ -30,6 +30,7 @@ export interface TableTdElementProps {
hasSelection?: boolean;
hasFooter?: boolean;
columnId: PropertyKey;
colIndex: number;
stickyState: StickyColumnsModel;
isVisualRefresh?: boolean;
tableRole: TableRole;
Expand Down Expand Up @@ -58,14 +59,15 @@ export const TableTdElement = React.forwardRef<HTMLTableCellElement, TableTdElem
hasSelection,
hasFooter,
columnId,
colIndex,
stickyState,
tableRole,
},
ref
) => {
const Element = isRowHeader ? 'th' : 'td';

nativeAttributes = { ...nativeAttributes, ...getTableCellRoleProps({ tableRole, isRowHeader }) };
nativeAttributes = { ...nativeAttributes, ...getTableCellRoleProps({ tableRole, isRowHeader, colIndex }) };

const stickyStyles = useStickyCellStyles({
stickyColumns: stickyState,
Expand Down
3 changes: 2 additions & 1 deletion src/table/header-cell/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export function TableHeaderCell<ItemType>({
columnId,
stickyState,
cellRef,
tableRole,
}: TableHeaderCellProps<ItemType>) {
const i18n = useInternalI18n('table');
const sortable = !!column.sortingComparator || !!column.sortingField;
Expand Down Expand Up @@ -112,7 +113,7 @@ export function TableHeaderCell<ItemType>({
)}
style={{ ...style, ...stickyStyles.style }}
ref={mergedRef}
{...getTableColHeaderRoleProps({ sortingStatus })}
{...getTableColHeaderRoleProps({ tableRole, sortingStatus, colIndex })}
>
<div
className={clsx(styles['header-cell-content'], {
Expand Down
Loading

0 comments on commit e87f4e7

Please sign in to comment.