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: Autolabel table and cards with header #1364

Merged
merged 6 commits into from
Aug 11, 2023
Merged
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
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
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

feels like this property was placed above the following text by accident

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
Loading