Skip to content

Commit

Permalink
feat: Autolabel table and cards with header (#1364)
Browse files Browse the repository at this point in the history
  • Loading branch information
Al-Dani authored Aug 11, 2023
1 parent e2002cd commit afa885b
Show file tree
Hide file tree
Showing 11 changed files with 90 additions and 23 deletions.
4 changes: 2 additions & 2 deletions src/__tests__/__snapshots__/documenter.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -12494,12 +12494,12 @@ scroll parent scrolls to reveal the first row of the table.",
* \`allItemsSelectionLabel\` ((SelectionState) => string) - Specifies the alternative text for multi-selection column header.
* \`selectionGroupLabel\` (string) - Specifies the alternative text for the whole selection and single-selection column header.
It is prefixed to \`itemSelectionLabel\` and \`allItemsSelectionLabel\` when they are set.
* \`tableLabel\` (string) - Provides an alternative text for the table. If you use a header for this table, you may reuse the string
to provide a caption-like description. For example, tableLabel=Instances will be announced as 'Instances table'.
You can use the first argument of type \`SelectionState\` to access the current selection
state of the component (for example, the \`selectedItems\` list). The \`itemSelectionLabel\` for individual
items also receives the corresponding \`Item\` object. You can use the \`selectionGroupLabel\` to
add a meaningful description to the whole selection.
* \`tableLabel\` (string) - Provides an alternative text for the table. If you use a header for this table, you may reuse the string
to provide a caption-like description. For example, tableLabel=Instances will be announced as 'Instances table'.
* \`activateEditLabel\` (EditableColumnDefinition, Item) => string -
Specifies an alternative text for the edit button in editable cells.
* \`cancelEditLabel\` (EditableColumnDefinition) => string -
Expand Down
11 changes: 10 additions & 1 deletion src/cards/__tests__/cards.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import * as React from 'react';
import { render } from '@testing-library/react';
import Cards, { CardsProps } from '../../../lib/components/cards';
import Header from '../../../lib/components/header';
import { CardsWrapper, PaginationWrapper } from '../../../lib/components/test-utils/dom';
import { useMobile } from '../../../lib/components/internal/hooks/use-mobile';
import liveRegionStyles from '../../../lib/components/internal/components/live-region/styles.css.js';
Expand Down Expand Up @@ -181,12 +182,20 @@ describe('Cards', () => {
expect(wrapper.findHeader()?.getElement()).toHaveTextContent('abcedefg');
});

it('maintains logical relationship between header and cards', () => {
it('maintains logical relationship between header and cards when header is a string', () => {
wrapper = renderCards(<Cards<Item> cardDefinition={{}} items={defaultItems} header="abcedefg" />).wrapper;
const cardsOrderedList = getCard(0).getElement().parentElement;
expect(cardsOrderedList).toHaveAccessibleName('abcedefg');
});

it('maintains logical relationship between header and cards when header is a component', () => {
wrapper = renderCards(
<Cards<Item> cardDefinition={{}} items={defaultItems} header={<Header>Cards header</Header>} />
).wrapper;
const cardsOrderedList = getCard(0).getElement().parentElement;
expect(cardsOrderedList).toHaveAccessibleName('Cards header');
});

it('allows label to be overridden', () => {
wrapper = renderCards(
<Cards<Item>
Expand Down
20 changes: 12 additions & 8 deletions src/cards/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import clsx from 'clsx';
import React, { FocusEventHandler, useImperativeHandle, useRef } from 'react';
import React, { FocusEventHandler, useCallback, useImperativeHandle, useRef } from 'react';
import { CardsForwardRefType, CardsProps } from './interfaces';
import styles from './styles.css.js';
import { getCardsPerRow } from './cards-layout-helper';
Expand All @@ -17,14 +17,14 @@ import stickyScrolling from '../table/sticky-scrolling';
import useBaseComponent from '../internal/hooks/use-base-component';
import { useVisualRefresh } from '../internal/hooks/use-visual-mode';
import { useMergeRefs } from '../internal/hooks/use-merge-refs';
import { useUniqueId } from '../internal/hooks/use-unique-id';
import LiveRegion from '../internal/components/live-region';
import useMouseDownTarget from '../internal/hooks/use-mouse-down-target';
import { useMobile } from '../internal/hooks/use-mobile';
import { supportsStickyPosition } from '../internal/utils/dom';
import { useInternalI18n } from '../i18n/context';
import { useContainerQuery } from '@cloudscape-design/component-toolkit';
import { AnalyticsFunnelSubStep } from '../internal/analytics/components/analytics-funnel';
import { CollectionLabelContext } from '../internal/context/collection-label-context';
import { LinkDefaultVariantContext } from '../internal/context/link-default-variant-context';

export { CardsProps };
Expand Down Expand Up @@ -64,9 +64,12 @@ const Cards = React.forwardRef(function <T = any>(
const isMobile = useMobile();

const computedVariant = isRefresh ? variant : 'container';
const instanceUniqueId = useUniqueId('cards');
const cardsId = baseProps?.id || instanceUniqueId;
const cardsHeaderId = header ? `${cardsId}-header` : undefined;

const headerIdRef = useRef<string | undefined>(undefined);
const setHeaderRef = useCallback((id: string) => {
headerIdRef.current = id;
}, []);
const isLabelledByHeader = !ariaLabels?.cardsLabel && !!header;

const [columns, measureRef] = useContainerQuery<number>(
({ contentBoxWidth }) => getCardsPerRow(contentBoxWidth, cardsPerRow),
Expand Down Expand Up @@ -141,7 +144,9 @@ const Cards = React.forwardRef(function <T = any>(
styles[`header-variant-${computedVariant}`]
)}
>
<ToolsHeader header={header} filter={filter} pagination={pagination} preferences={preferences} />
<CollectionLabelContext.Provider value={{ assignId: setHeaderRef }}>
<ToolsHeader header={header} filter={filter} pagination={pagination} preferences={preferences} />
</CollectionLabelContext.Provider>
</div>
)
}
Expand All @@ -152,7 +157,6 @@ const Cards = React.forwardRef(function <T = any>(
__stickyHeader={stickyHeader}
__stickyOffset={stickyHeaderVerticalOffset}
__headerRef={headerRef}
__headerId={cardsHeaderId}
__darkHeader={computedVariant === 'full-page'}
__disableFooterDivider={true}
>
Expand All @@ -177,7 +181,7 @@ const Cards = React.forwardRef(function <T = any>(
updateShiftToggle={updateShiftToggle}
onFocus={onCardFocus}
ariaLabel={ariaLabels?.cardsLabel}
ariaLabelledby={ariaLabels?.cardsLabel ? undefined : cardsHeaderId}
ariaLabelledby={isLabelledByHeader && headerIdRef.current ? headerIdRef.current : undefined}
/>
)}
</div>
Expand Down
4 changes: 0 additions & 4 deletions src/container/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ export interface InternalContainerProps extends Omit<ContainerProps, 'variant'>,
__disableFooterPaddings?: boolean;
__hiddenContent?: boolean;
__headerRef?: React.RefObject<HTMLDivElement>;
__headerId?: string;
__darkHeader?: boolean;
__disableStickyMobile?: boolean;
/**
Expand Down Expand Up @@ -51,7 +50,6 @@ export default function InternalContainer({
__disableFooterPaddings = false,
__hiddenContent = false,
__headerRef,
__headerId,
__darkHeader = false,
__disableStickyMobile = true,
...restProps
Expand All @@ -77,7 +75,6 @@ export default function InternalContainer({

const mergedRef = useMergeRefs(rootRef, subStepRef, __internalRootRef);
const headerMergedRef = useMergeRefs(headerRef, overlapElement, __headerRef);
const headerIdProp = __headerId ? { id: __headerId } : {};

/**
* The visual refresh AppLayout component needs to know if a child component
Expand Down Expand Up @@ -139,7 +136,6 @@ export default function InternalContainer({
[styles['with-hidden-content']]: !children || __hiddenContent,
[styles['header-with-media']]: hasMedia,
})}
{...headerIdProp}
{...stickyStyles}
ref={headerMergedRef}
>
Expand Down
5 changes: 5 additions & 0 deletions src/header/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import styles from './styles.css.js';
import { SomeRequired } from '../internal/types';
import { useMobile } from '../internal/hooks/use-mobile';
import { InfoLinkLabelContext } from '../internal/context/info-link-label-context';
import { CollectionLabelContext } from '../internal/context/collection-label-context';
import { useUniqueId } from '../internal/hooks/use-unique-id';
import { DATA_ATTR_FUNNEL_KEY, FUNNEL_KEY_SUBSTEP_NAME } from '../internal/analytics/selectors';

Expand All @@ -35,7 +36,11 @@ export default function InternalHeader({
const { isStuck } = useContext(StickyHeaderContext);
const baseProps = getBaseProps(restProps);
const isRefresh = useVisualRefresh();
const assignHeaderId = useContext(CollectionLabelContext).assignId;
const headingId = useUniqueId('heading');
if (assignHeaderId !== undefined) {
assignHeaderId(headingId);
}
// If is mobile there is no need to have the dynamic variant because it's scrolled out of view
const dynamicVariant = !isMobile && isStuck ? 'h2' : 'h1';
const variantOverride = variant === 'awsui-h1-sticky' ? (isRefresh ? dynamicVariant : 'h2') : variant;
Expand Down
9 changes: 9 additions & 0 deletions src/internal/context/collection-label-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { createContext } from 'react';

interface CollectionLabellingInterface {
assignId?: (id: string) => void;
}

export const CollectionLabelContext = createContext<CollectionLabellingInterface>({});
16 changes: 16 additions & 0 deletions src/table/__tests__/a11y.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import React from 'react';

import createWrapper from '../../../lib/components/test-utils/dom';
import Table, { TableProps } from '../../../lib/components/table';
import Header from '../../../lib/components/header';
import { render } from '@testing-library/react';
import liveRegionStyles from '../../../lib/components/internal/components/live-region/styles.css.js';

Expand Down Expand Up @@ -68,6 +69,21 @@ describe('labels', () => {
expect(wrapper.find('[role=table]')!.getElement().getAttribute('aria-label')).toEqual(tableLabel);
});

test('automatically labels table with header if provided', () => {
const wrapper = renderTableWrapper({
header: <Header counter="(10)">Labelled table</Header>,
});
expect(wrapper.find('[role=table]')!.getElement()).toHaveAccessibleName('Labelled table');
});

test('aria-label has priority over auto-labelling', () => {
const wrapper = renderTableWrapper({
header: <Header>Labelled table</Header>,
ariaLabels: { itemSelectionLabel: () => '', selectionGroupLabel: '', tableLabel },
});
expect(wrapper.find('[role=table]')!.getElement()).toHaveAccessibleName(tableLabel);
});

describe('rows', () => {
test('sets aria-rowcount on table', () => {
const wrapper = renderTableWrapper({
Expand Down
5 changes: 2 additions & 3 deletions src/table/interfaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,12 @@ export interface TableProps<T = any> extends BaseComponentProps {
* * `allItemsSelectionLabel` ((SelectionState) => string) - Specifies the alternative text for multi-selection column header.
* * `selectionGroupLabel` (string) - Specifies the alternative text for the whole selection and single-selection column header.
* It is prefixed to `itemSelectionLabel` and `allItemsSelectionLabel` when they are set.
* * `tableLabel` (string) - Provides an alternative text for the table. If you use a header for this table, you may reuse the string
* to provide a caption-like description. For example, tableLabel=Instances will be announced as 'Instances table'.
* You can use the first argument of type `SelectionState` to access the current selection
* state of the component (for example, the `selectedItems` list). The `itemSelectionLabel` for individual
* items also receives the corresponding `Item` object. You can use the `selectionGroupLabel` to
* add a meaningful description to the whole selection.
*
* * `tableLabel` (string) - Provides an alternative text for the table. If you use a header for this table, you may reuse the string
* to provide a caption-like description. For example, tableLabel=Instances will be announced as 'Instances table'.
* * `activateEditLabel` (EditableColumnDefinition, Item) => string -
* Specifies an alternative text for the edit button in editable cells.
* * `cancelEditLabel` (EditableColumnDefinition) => string -
Expand Down
25 changes: 22 additions & 3 deletions src/table/internal.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import clsx from 'clsx';
import React, { useImperativeHandle, useRef } from 'react';
import React, { useCallback, useImperativeHandle, useRef } from 'react';
import { TableForwardRefType, TableProps } from './interfaces';
import { getVisualContextClassname } from '../internal/components/visual-context';
import InternalContainer from '../container/internal';
Expand Down Expand Up @@ -39,6 +39,7 @@ import { useContainerQuery } from '@cloudscape-design/component-toolkit';
import { getTableRoleProps, getTableRowRoleProps } from './table-role';
import { useCellEditing } from './use-cell-editing';
import { LinkDefaultVariantContext } from '../internal/context/link-default-variant-context';
import { CollectionLabelContext } from '../internal/context/collection-label-context';

const SELECTION_COLUMN_WIDTH = 54;
const selectionColumnId = Symbol('selection-column-id');
Expand Down Expand Up @@ -159,6 +160,12 @@ const InternalTable = React.forwardRef(
const hasFooterPagination = isMobile && variant === 'full-page' && !!pagination;
const hasFooter = !!footer || hasFooterPagination;

const headerIdRef = useRef<string | undefined>(undefined);
const isLabelledByHeader = !ariaLabels?.tableLabel && !!header;
const setHeaderRef = useCallback((id: string) => {
headerIdRef.current = id;
}, []);

const visibleColumnWidthsWithSelection: ColumnWidthDefinition[] = [];
const visibleColumnIdsWithSelection: PropertyKey[] = [];
if (hasSelection) {
Expand Down Expand Up @@ -252,7 +259,14 @@ const InternalTable = React.forwardRef(
ref={toolsHeaderWrapper}
className={clsx(styles['header-controls'], styles[`variant-${computedVariant}`])}
>
<ToolsHeader header={header} filter={filter} pagination={pagination} preferences={preferences} />
<CollectionLabelContext.Provider value={{ assignId: setHeaderRef }}>
<ToolsHeader
header={header}
filter={filter}
pagination={pagination}
preferences={preferences}
/>
</CollectionLabelContext.Provider>
</div>
</div>
)}
Expand Down Expand Up @@ -318,7 +332,12 @@ const InternalTable = React.forwardRef(
resizableColumns && styles['table-layout-fixed'],
contentDensity === 'compact' && getVisualContextClassname('compact-table')
)}
{...getTableRoleProps({ tableRole, totalItemsCount, ariaLabel: ariaLabels?.tableLabel })}
{...getTableRoleProps({
tableRole,
totalItemsCount,
ariaLabel: ariaLabels?.tableLabel,
ariaLabelledBy: isLabelledByHeader && headerIdRef.current ? headerIdRef.current : undefined,
})}
>
<Thead
ref={theadRef}
Expand Down
2 changes: 2 additions & 0 deletions src/table/table-role/table-role-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type TableRole = 'table' | 'grid';
export function getTableRoleProps(options: {
tableRole: TableRole;
ariaLabel?: string;
ariaLabelledBy?: string;
totalItemsCount?: number;
}): React.TableHTMLAttributes<HTMLTableElement> {
const nativeProps: React.TableHTMLAttributes<HTMLTableElement> = {};
Expand All @@ -28,6 +29,7 @@ export function getTableRoleProps(options: {
nativeProps.role = options.tableRole;

nativeProps['aria-label'] = options.ariaLabel;
nativeProps['aria-labelledby'] = options.ariaLabelledBy;

// Incrementing the total count by one to account for the header row.
nativeProps['aria-rowcount'] = options.totalItemsCount ? options.totalItemsCount + 1 : -1;
Expand Down
12 changes: 10 additions & 2 deletions src/table/tools-header.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import clsx from 'clsx';
import React from 'react';
import React, { useContext } from 'react';
import { useContainerBreakpoints } from '../internal/hooks/container-queries';
import styles from './styles.css.js';
import { CollectionLabelContext } from '../internal/context/collection-label-context';
import { useUniqueId } from '../internal/hooks/use-unique-id';

interface ToolsHeaderProps {
header: React.ReactNode;
Expand All @@ -14,11 +16,17 @@ interface ToolsHeaderProps {

export default function ToolsHeader({ header, filter, pagination, preferences }: ToolsHeaderProps) {
const [breakpoint, ref] = useContainerBreakpoints(['xs']);
const isHeaderString = typeof header === 'string';
const assignHeaderId = useContext(CollectionLabelContext).assignId;
const headingId = useUniqueId('heading');
if (assignHeaderId !== undefined && isHeaderString) {
assignHeaderId(headingId);
}
const isSmall = breakpoint === 'default';
const hasTools = filter || pagination || preferences;
return (
<>
{header}
{isHeaderString ? <span id={headingId}>{header}</span> : header}
{hasTools && (
<div ref={ref} className={clsx(styles.tools, isSmall && styles['tools-small'])}>
{filter && <div className={styles['tools-filtering']}>{filter}</div>}
Expand Down

0 comments on commit afa885b

Please sign in to comment.