diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index 582c79a176..ca304f69fc 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -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 - diff --git a/src/cards/__tests__/cards.test.tsx b/src/cards/__tests__/cards.test.tsx index 7aff0105b7..bf89142b9b 100644 --- a/src/cards/__tests__/cards.test.tsx +++ b/src/cards/__tests__/cards.test.tsx @@ -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'; @@ -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( 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( + cardDefinition={{}} items={defaultItems} header={
Cards header
} /> + ).wrapper; + const cardsOrderedList = getCard(0).getElement().parentElement; + expect(cardsOrderedList).toHaveAccessibleName('Cards header'); + }); + it('allows label to be overridden', () => { wrapper = renderCards( diff --git a/src/cards/index.tsx b/src/cards/index.tsx index 2d19307339..5c75bb1128 100644 --- a/src/cards/index.tsx +++ b/src/cards/index.tsx @@ -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'; @@ -17,7 +17,6 @@ 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'; @@ -25,6 +24,7 @@ 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 }; @@ -64,9 +64,12 @@ const Cards = React.forwardRef(function ( 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(undefined); + const setHeaderRef = useCallback((id: string) => { + headerIdRef.current = id; + }, []); + const isLabelledByHeader = !ariaLabels?.cardsLabel && !!header; const [columns, measureRef] = useContainerQuery( ({ contentBoxWidth }) => getCardsPerRow(contentBoxWidth, cardsPerRow), @@ -141,7 +144,9 @@ const Cards = React.forwardRef(function ( styles[`header-variant-${computedVariant}`] )} > - + + + ) } @@ -152,7 +157,6 @@ const Cards = React.forwardRef(function ( __stickyHeader={stickyHeader} __stickyOffset={stickyHeaderVerticalOffset} __headerRef={headerRef} - __headerId={cardsHeaderId} __darkHeader={computedVariant === 'full-page'} __disableFooterDivider={true} > @@ -177,7 +181,7 @@ const Cards = React.forwardRef(function ( updateShiftToggle={updateShiftToggle} onFocus={onCardFocus} ariaLabel={ariaLabels?.cardsLabel} - ariaLabelledby={ariaLabels?.cardsLabel ? undefined : cardsHeaderId} + ariaLabelledby={isLabelledByHeader && headerIdRef.current ? headerIdRef.current : undefined} /> )} diff --git a/src/container/internal.tsx b/src/container/internal.tsx index 3c483b65a9..0a2b6bd63c 100644 --- a/src/container/internal.tsx +++ b/src/container/internal.tsx @@ -22,7 +22,6 @@ export interface InternalContainerProps extends Omit, __disableFooterPaddings?: boolean; __hiddenContent?: boolean; __headerRef?: React.RefObject; - __headerId?: string; __darkHeader?: boolean; __disableStickyMobile?: boolean; /** @@ -51,7 +50,6 @@ export default function InternalContainer({ __disableFooterPaddings = false, __hiddenContent = false, __headerRef, - __headerId, __darkHeader = false, __disableStickyMobile = true, ...restProps @@ -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 @@ -139,7 +136,6 @@ export default function InternalContainer({ [styles['with-hidden-content']]: !children || __hiddenContent, [styles['header-with-media']]: hasMedia, })} - {...headerIdProp} {...stickyStyles} ref={headerMergedRef} > diff --git a/src/header/internal.tsx b/src/header/internal.tsx index 21ea90159d..5c53c725fb 100644 --- a/src/header/internal.tsx +++ b/src/header/internal.tsx @@ -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'; @@ -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; diff --git a/src/internal/context/collection-label-context.ts b/src/internal/context/collection-label-context.ts new file mode 100644 index 0000000000..b7e973e33f --- /dev/null +++ b/src/internal/context/collection-label-context.ts @@ -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({}); diff --git a/src/table/__tests__/a11y.test.tsx b/src/table/__tests__/a11y.test.tsx index 533f7d3fda..3773847f5c 100644 --- a/src/table/__tests__/a11y.test.tsx +++ b/src/table/__tests__/a11y.test.tsx @@ -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'; @@ -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:
Labelled table
, + }); + expect(wrapper.find('[role=table]')!.getElement()).toHaveAccessibleName('Labelled table'); + }); + + test('aria-label has priority over auto-labelling', () => { + const wrapper = renderTableWrapper({ + header:
Labelled table
, + ariaLabels: { itemSelectionLabel: () => '', selectionGroupLabel: '', tableLabel }, + }); + expect(wrapper.find('[role=table]')!.getElement()).toHaveAccessibleName(tableLabel); + }); + describe('rows', () => { test('sets aria-rowcount on table', () => { const wrapper = renderTableWrapper({ diff --git a/src/table/interfaces.tsx b/src/table/interfaces.tsx index 8f158f48ce..95dab4b5e7 100644 --- a/src/table/interfaces.tsx +++ b/src/table/interfaces.tsx @@ -161,13 +161,12 @@ export interface TableProps 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 - diff --git a/src/table/internal.tsx b/src/table/internal.tsx index d8de7db369..fa2c67a818 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -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'; @@ -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'); @@ -159,6 +160,12 @@ const InternalTable = React.forwardRef( const hasFooterPagination = isMobile && variant === 'full-page' && !!pagination; const hasFooter = !!footer || hasFooterPagination; + const headerIdRef = useRef(undefined); + const isLabelledByHeader = !ariaLabels?.tableLabel && !!header; + const setHeaderRef = useCallback((id: string) => { + headerIdRef.current = id; + }, []); + const visibleColumnWidthsWithSelection: ColumnWidthDefinition[] = []; const visibleColumnIdsWithSelection: PropertyKey[] = []; if (hasSelection) { @@ -252,7 +259,14 @@ const InternalTable = React.forwardRef( ref={toolsHeaderWrapper} className={clsx(styles['header-controls'], styles[`variant-${computedVariant}`])} > - + + + )} @@ -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, + })} > { const nativeProps: React.TableHTMLAttributes = {}; @@ -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; diff --git a/src/table/tools-header.tsx b/src/table/tools-header.tsx index eaa7436d50..86dcebd6ad 100644 --- a/src/table/tools-header.tsx +++ b/src/table/tools-header.tsx @@ -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; @@ -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 ? {header} : header} {hasTools && (
{filter &&
{filter}
}