diff --git a/packages/main/src/components/ObjectPage/ObjectPage.stories.tsx b/packages/main/src/components/ObjectPage/ObjectPage.stories.tsx index 7ec697c637b..b501c31d53e 100644 --- a/packages/main/src/components/ObjectPage/ObjectPage.stories.tsx +++ b/packages/main/src/components/ObjectPage/ObjectPage.stories.tsx @@ -167,7 +167,7 @@ export const Default: Story = { return ( // `onBeforeNavigate` & `onToggleHeaderArea` have to be added like this, otherwise Storybook invokes them incorrectly sometimes - +
Evangelize the UI framework across the company}> 4 days overdue - Cascaded @@ -180,11 +180,10 @@ export const Default: Story = {
- + + + ); +}; + +export const ObjectPageSingleSectionIconTabBarTestComp = () => { + return ( + + + Single section content + + + ); +}; diff --git a/packages/main/src/components/ObjectPage/test/ObjectPage.spec.tsx b/packages/main/src/components/ObjectPage/test/ObjectPage.spec.tsx index 00f76fdede6..9dce0079a9b 100644 --- a/packages/main/src/components/ObjectPage/test/ObjectPage.spec.tsx +++ b/packages/main/src/components/ObjectPage/test/ObjectPage.spec.tsx @@ -126,4 +126,53 @@ test.describe('ObjectPage', () => { await page.getByRole('tab', { name: 'Short' }).click(); await expect.poll(topSpacerHeight).toBe(0); }); + + test('uses titleText as the accessible name and respects explicit names', async ({ mount, page }) => { + await mount('ObjectPage/ObjectPageAccessibleNameTestComp'); + + const automaticSection = page.getByRole('region', { name: 'Automatic section' }); + const automaticSubsection = page.getByRole('region', { name: 'Automatic subsection' }); + + await expect(automaticSection).toHaveAttribute('aria-label', 'Automatic section'); + await expect(automaticSubsection).toHaveAttribute('aria-label', 'Automatic subsection'); + + await expect(page.getByRole('region', { name: 'Custom section label' })).toHaveAttribute( + 'aria-label', + 'Custom section label', + ); + await expect(page.getByRole('region', { name: 'External section label' })).toHaveAttribute( + 'aria-labelledby', + 'external-section-label', + ); + }); + + test('keeps only a single section out of the tab order', async ({ mount, page }) => { + await mount('ObjectPage/ObjectPageSingleSectionTestComp'); + + const section = page.getByRole('region', { name: 'Single section' }); + const firstSubSection = page.getByRole('region', { name: 'First subsection' }); + const secondSubSection = page.getByRole('region', { name: 'Second subsection' }); + + expect(await section.getAttribute('tabindex')).toBeNull(); + await expect(section).toHaveAttribute('aria-label', 'Single section'); + await expect(firstSubSection).toHaveAttribute('tabindex', '0'); + await expect(page.locator('[data-component-name="ObjectPageTabContainer"]')).toHaveCount(0); + + await firstSubSection.focus(); + await page.keyboard.press('ArrowDown'); + await expect(secondSubSection).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(page.getByRole('button', { name: 'After ObjectPage' })).toBeFocused(); + await page.keyboard.press('Shift+Tab'); + await expect(secondSubSection).toBeFocused(); + expect(await section.getAttribute('tabindex')).toBeNull(); + + await mount('ObjectPage/ObjectPageSingleSectionIconTabBarTestComp'); + expect(await page.getByRole('region', { name: 'Single section' }).getAttribute('tabindex')).toBeNull(); + await expect(page.locator('[data-component-name="ObjectPageTabContainer"]')).toHaveCount(0); + + await mount('ObjectPage/ObjectPageFitContentTabSwitchTestComp'); + await expect(page.getByRole('region', { name: 'Fit 1' })).toHaveAttribute('tabindex', '0'); + await expect(page.locator('[data-component-name="ObjectPageTabContainer"]').first()).toBeVisible(); + }); }); diff --git a/packages/main/src/components/ObjectPageSection/index.tsx b/packages/main/src/components/ObjectPageSection/index.tsx index f08d3907deb..351dfd05bea 100644 --- a/packages/main/src/components/ObjectPageSection/index.tsx +++ b/packages/main/src/components/ObjectPageSection/index.tsx @@ -28,6 +28,8 @@ export interface ObjectPageSectionPropTypes extends CommonProps { /** * Defines the title of the `ObjectPageSection`. * + * __Note:__ The title is also used as the accessible name of the section unless `aria-label` or `aria-labelledby` is provided. + * */ titleText: string; /** @@ -137,6 +139,8 @@ const ObjectPageSection = forwardRef((p wrapTitleText, header, fitContent, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, ...rest } = props; @@ -154,20 +158,21 @@ const ObjectPageSection = forwardRef((p ), [children], ); - const objectPageMode = useObjectPageContext(); + const { mode: objectPageMode, hasOnlySingleSection } = useObjectPageContext(); const handleFocusDefault = (e: FocusEvent) => { if (typeof props.onFocus === 'function') { props.onFocus(e); } - // reset tab-index of all sections, so only the focused one is tabbable - Array.from(e.currentTarget.parentElement.children as HTMLCollectionOf).forEach((el) => { - if (el.dataset.componentName === 'ObjectPageSection') { - el.tabIndex = -1; - } - }); - - e.currentTarget.tabIndex = 0; + if (!hasOnlySingleSection) { + // reset tab-index of all sections, so only the focused one is tabbable + Array.from(e.currentTarget.parentElement.children as HTMLCollectionOf).forEach((el) => { + if (el.dataset.componentName === 'ObjectPageSection') { + el.tabIndex = -1; + } + }); + e.currentTarget.tabIndex = 0; + } if (setTabIndexForFirstSubSectionIfFocused(e, e.currentTarget, hasSubSection)) { // if section has subsections, the first subsection should be next in the tab-chain } else if (e.target.dataset.componentName === 'ObjectPageSubSection') { @@ -255,8 +260,10 @@ const ObjectPageSection = forwardRef((p className, )} style={style} - tabIndex={objectPageMode === ObjectPageMode.Default ? -1 : 0} + tabIndex={hasOnlySingleSection ? undefined : objectPageMode === ObjectPageMode.Default ? -1 : 0} {...propsWithoutOmitted} + aria-label={ariaLabel ?? (ariaLabelledBy == null ? titleText : undefined)} + aria-labelledby={ariaLabelledBy} id={htmlId} data-component-name="ObjectPageSection" onFocus={objectPageMode === ObjectPageMode.Default ? handleFocusDefault : handleFocusIconTabBar} diff --git a/packages/main/src/components/ObjectPageSubSection/index.tsx b/packages/main/src/components/ObjectPageSubSection/index.tsx index 269aa7e9d11..c415d2ef81c 100644 --- a/packages/main/src/components/ObjectPageSubSection/index.tsx +++ b/packages/main/src/components/ObjectPageSubSection/index.tsx @@ -10,6 +10,7 @@ import { FlexBoxDirection } from '../../enums/FlexBoxDirection.js'; import { FlexBoxJustifyContent } from '../../enums/FlexBoxJustifyContent.js'; import type { CommonProps } from '../../types/index.js'; import { FlexBox } from '../FlexBox/index.js'; +import { useObjectPageContext } from '../ObjectPage/context.js'; import { navigateSections } from '../ObjectPage/ObjectPageUtils.js'; import { classNames, styleData } from './ObjectPageSubSection.module.css.js'; @@ -24,6 +25,8 @@ export interface ObjectPageSubSectionPropTypes extends CommonProps { children: ReactNode | ReactNode[]; /** * Defines the title of the `ObjectPageSubSection`. + * + * __Note:__ The title is also used as the accessible name of the subsection unless `aria-label` or `aria-labelledby` is provided. */ titleText: string; /** @@ -71,9 +74,12 @@ const ObjectPageSubSection = forwardRef { navigateSections({ e, onKeyDown: props.onKeyDown, componentName: 'ObjectPageSubSection' }); }} @@ -93,7 +101,7 @@ const ObjectPageSubSection = forwardRef