diff --git a/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx b/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx index dc37d17c5d9..2f94a144ad4 100644 --- a/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx +++ b/packages/main/src/components/ObjectPage/ObjectPage.cy.tsx @@ -1433,6 +1433,311 @@ describe('ObjectPage', () => { }); cypressPassThroughTestsFactory(ObjectPage); + + it('focus behavior & keyboard navigation', () => { + cy.mount( + <> + + + +
+ Evangelize the UI framework across the company}> + 4 days overdue - Cascaded + + Get trained in development management direction}> + Due Nov, 21 + + Mentor junior developers}> + Due Dec, 31 - Cascaded + +
+
+ + Dummy + + + + + + } + > +
+ + Home}> + +1 234-567-8901 + +1 234-567-5555 + + + + LinkedIn}> + /DeniseSmith + + Twitter}> + @DeniseSmith + + + + Home Address}> + 2096 Mission Street + + Mailing Address}> + PO Box 32114 + + + + Work}> + DeniseSmith@sap.com + + +
+
+ +
+ + Bank Transfer}> + Money Bank, Inc. + + + + Extra Travel Expenses}> + Cash 100 USD + + +
+
+
+ + +
+ Job Classification}> + + Senior UI Developer + + + + Job Title}> + Developer + + Employee Class}> + Employee + + Manager}> + + Dan Smith + + + + Pay Grade}> + Salary Grade 18 (GR-14) + + FTE}> + 1 + +
+
+ +
+ Start Date}> + Jan 01, 2018 + + End Date}> + Dec 31, 9999 + + Payroll Start Date}> + Jan 01, 2018 + + Benefits Start Date}> + Jul 01, 2018 + + Company Car Eligibility}> + Jan 01, 2021 + + Equity Start Date}> + Jul 01, 2018 + +
+
+ +
+ Manager}> + John Doe + + Scrum Master}> + Michael Adams + + Product Owner}> + John Miller + +
+
+
+ + + + + + Some Text + + + + + +
+ , + ); + + cy.get('[data-component-name="ObjectPageSection"]').as('sections'); + cy.get('@sections').eq(0).should('have.attr', 'tabindex', 0); + cy.get('@sections').each((section, index) => { + if (index !== 0) { + cy.wrap(section).should('have.attr', 'tabindex', -1); + } + }); + cy.get('[data-component-name="ObjectPageSubSection"]').should('have.attr', 'tabindex', -1); + + cy.findByTestId('start').focus(); + // breadcrumbs + cy.realPress('Tab'); + //toolbar + cy.realPress('Tab'); + cy.realPress('Tab'); + // header content (links) + cy.realPress('Tab'); + cy.realPress('Tab'); + cy.realPress('Tab'); + // anchor buttons + cy.realPress('Tab'); + cy.realPress('Tab'); + // tabbar + cy.realPress('Tab'); + // first section + cy.realPress('Tab'); + cy.focused().should('have.attr', 'aria-label', 'Goals').and('have.attr', 'tabindex', 0); + // Personal: custom action + cy.realPress('Tab'); + cy.findByTestId('customAction').should('be.focused'); + // SingleSectionInput + cy.realPress('Tab'); + cy.findByTestId('single').should('be.focused'); + // 6.2 input + cy.realPress('Tab'); + cy.findByTestId('sub').should('be.focused'); + //footer + cy.realPress('Tab'); + cy.findByTestId('footer-accept-btn').should('be.focused'); + // 6.2 input + cy.realPress(['Shift', 'Tab']); + cy.findByTestId('sub').should('be.focused'); + // 6.2 subsection + cy.realPress(['Shift', 'Tab']); + cy.focused().should('have.attr', 'aria-label', '6.2').and('have.attr', 'tabindex', 0); + // SubSectionsInput + cy.realPress(['Shift', 'Tab']); + cy.focused().should('have.attr', 'aria-label', 'SubSectionsInput').and('have.attr', 'tabindex', 0); + // SingleSectionInput + cy.realPress(['Shift', 'Tab']); + cy.findByTestId('single').should('be.focused'); + // section SingleSectionInput + cy.realPress(['Shift', 'Tab']); + cy.focused().should('have.attr', 'aria-label', 'SingleSectionInput').and('have.attr', 'tabindex', 0); + // Personal: custom action btn + cy.realPress(['Shift', 'Tab']); + cy.findByTestId('customAction').should('be.focused'); + // Personal: Connect - subsection + cy.realPress(['Shift', 'Tab']); + cy.focused().should('have.attr', 'aria-label', 'Connect').and('have.attr', 'tabindex', 0); + // Personal: section + cy.realPress(['Shift', 'Tab']); + cy.focused().should('have.attr', 'aria-label', 'Personal').and('have.attr', 'tabindex', 0); + // tabbar + cy.realPress(['Shift', 'Tab']); + + cy.get('@sections').eq(2).should('have.attr', 'tabindex', 0); + cy.get('@sections').each((section, index) => { + if (index !== 2) { + cy.wrap(section).should('have.attr', 'tabindex', -1); + } + }); + cy.get('[data-component-name="ObjectPageSubSection"]').should('have.attr', 'tabindex', -1); + + // click first Tab + cy.focused().realClick(); + cy.focused().should('have.attr', 'aria-label', 'Goals').and('have.attr', 'tabindex', 0); + + // arrow section navigation + cy.realPress('ArrowUp'); + cy.focused().should('have.attr', 'aria-label', 'Goals').and('have.attr', 'tabindex', 0); + cy.realPress('ArrowDown'); + cy.focused().should('have.attr', 'aria-label', 'Dummy').and('have.attr', 'tabindex', 0); + cy.realPress('ArrowDown'); + cy.focused().should('have.attr', 'aria-label', 'Personal').and('have.attr', 'tabindex', 0); + cy.realPress('ArrowDown'); + cy.focused().should('have.attr', 'aria-label', 'Employment').and('have.attr', 'tabindex', 0); + cy.realPress('ArrowDown'); + cy.focused().should('have.attr', 'aria-label', 'SingleSectionInput').and('have.attr', 'tabindex', 0); + cy.realPress('ArrowDown'); + cy.focused().should('have.attr', 'aria-label', 'SubSectionsInput').and('have.attr', 'tabindex', 0); + cy.realPress('ArrowDown'); + cy.focused().should('have.attr', 'aria-label', 'SubSectionsInput').and('have.attr', 'tabindex', 0); + + // arrow subsection navigation + cy.realPress('Tab'); + cy.focused().should('have.attr', 'aria-label', '6.1').and('have.attr', 'tabindex', 0); + cy.realPress('ArrowUp'); + cy.focused().should('have.attr', 'aria-label', '6.1').and('have.attr', 'tabindex', 0); + cy.realPress('ArrowDown'); + cy.focused().should('have.attr', 'aria-label', '6.2').and('have.attr', 'tabindex', 0); + + cy.get('[ui5-tabcontainer]').findUi5TabOpenPopoverButtonByText('Employment').click(); + cy.get('[ui5-responsive-popover]').should('be.visible'); + cy.realPress('ArrowDown'); + cy.realPress('Enter'); + cy.focused().should('have.attr', 'aria-label', 'Employee Details').and('have.attr', 'tabindex', 0); + + cy.get('[data-component-name="ObjectPageSection"]').as('sections'); + cy.get('@sections').eq(3).should('have.attr', 'tabindex', 0); + cy.get('@sections').each((section, index) => { + if (index !== 3) { + cy.wrap(section).should('have.attr', 'tabindex', -1); + } + }); + cy.get('[data-component-name="ObjectPageSubSection"]').as('subsections'); + cy.get('@subsections').eq(3).should('have.attr', 'tabindex', 0); + cy.get('@subsections').each((section, index) => { + if (index !== 3) { + cy.wrap(section).should('have.attr', 'tabindex', -1); + } + }); + }); }); const DPTitle = ( @@ -1602,7 +1907,9 @@ const Footer = ( design={BarDesign.FloatingFooter} endContent={ <> - + } diff --git a/packages/main/src/components/ObjectPage/ObjectPage.module.css b/packages/main/src/components/ObjectPage/ObjectPage.module.css index 1614a3c46c0..9ed2bc09703 100644 --- a/packages/main/src/components/ObjectPage/ObjectPage.module.css +++ b/packages/main/src/components/ObjectPage/ObjectPage.module.css @@ -130,8 +130,7 @@ @container (max-width: 599px) { .header, - .headerContainer, - .content { + .headerContainer { padding-inline: 1rem; } @@ -142,8 +141,7 @@ @container (min-width: 600px) and (max-width: 1439px) { .header, - .headerContainer, - .content { + .headerContainer { padding-inline: 2rem; } @@ -154,8 +152,7 @@ @container (min-width: 1440px) { .header, - .headerContainer, - .content { + .headerContainer { padding-inline: 3rem; } diff --git a/packages/main/src/components/ObjectPage/ObjectPageUtils.ts b/packages/main/src/components/ObjectPage/ObjectPageUtils.ts index 4c4c7e94fbb..0b5fae9470a 100644 --- a/packages/main/src/components/ObjectPage/ObjectPageUtils.ts +++ b/packages/main/src/components/ObjectPage/ObjectPageUtils.ts @@ -1,4 +1,4 @@ -import type { ReactElement } from 'react'; +import type { ReactElement, KeyboardEvent } from 'react'; import { isValidElement } from 'react'; import { safeGetChildrenArray } from '../../internal/safeGetChildrenArray.js'; import type { ObjectPageSectionPropTypes } from '../ObjectPageSection/index.js'; @@ -17,3 +17,44 @@ export const getSectionElementById = (objectPage: HTMLDivElement, isSubSection: `#${isSubSection ? 'ObjectPageSubSection' : 'ObjectPageSection'}-${CSS.escape(id)}`, ); }; + +interface NavigateSectionParam { + e: KeyboardEvent; + onKeyDown: (e: KeyboardEvent) => void; + componentName: 'ObjectPageSection' | 'ObjectPageSubSection'; +} + +export function navigateSections({ e, onKeyDown, componentName }: NavigateSectionParam) { + if (typeof onKeyDown === 'function') { + onKeyDown(e); + } + if (e.currentTarget !== e.target) { + return; + } + + const nextSibling = e.currentTarget.nextElementSibling as HTMLElement; + const prevSibling = e.currentTarget.previousElementSibling as HTMLElement; + if ( + nextSibling && + (e.key === 'ArrowDown' || e.key === 'ArrowRight') && + nextSibling.dataset.componentName === componentName + ) { + e.preventDefault(); + e.currentTarget.tabIndex = -1; + nextSibling.tabIndex = 0; + nextSibling.focus({ preventScroll: true }); + nextSibling.scrollIntoView({ behavior: 'instant', block: 'start' }); + } + + if ( + prevSibling && + (e.key === 'ArrowUp' || e.key === 'ArrowLeft') && + prevSibling.dataset.componentName === componentName + ) { + e.preventDefault(); + e.currentTarget.tabIndex = -1; + prevSibling.tabIndex = 0; + prevSibling.focus({ preventScroll: true }); + prevSibling.scrollIntoView({ behavior: 'instant', block: 'start' }); + } +} diff --git a/packages/main/src/components/ObjectPage/context.ts b/packages/main/src/components/ObjectPage/context.ts new file mode 100644 index 00000000000..ea970d117ca --- /dev/null +++ b/packages/main/src/components/ObjectPage/context.ts @@ -0,0 +1,7 @@ +import { createContext, useContext } from 'react'; +import { ObjectPageMode } from '../../enums/ObjectPageMode.js'; +import type { ObjectPagePropTypes } from './types/index.js'; + +export const ObjectPageContext = createContext(ObjectPageMode.Default); + +export const useObjectPageContext = () => useContext(ObjectPageContext); diff --git a/packages/main/src/components/ObjectPage/index.tsx b/packages/main/src/components/ObjectPage/index.tsx index fdc7653f3f7..ed2f70d39a1 100644 --- a/packages/main/src/components/ObjectPage/index.tsx +++ b/packages/main/src/components/ObjectPage/index.tsx @@ -23,6 +23,7 @@ import type { InternalProps as ObjectPageHeaderPropTypesWithInternals } from '.. import type { ObjectPageSectionPropTypes } from '../ObjectPageSection/index.js'; import type { ObjectPageSubSectionPropTypes } from '../ObjectPageSubSection/index.js'; import { CollapsedAvatar } from './CollapsedAvatar.js'; +import { ObjectPageContext } from './context.js'; import { classNames, styleData } from './ObjectPage.module.css.js'; import { getSectionById, getSectionElementById } from './ObjectPageUtils.js'; import type { @@ -95,6 +96,7 @@ const ObjectPage = forwardRef((props, ref const selectionScrollTimeout = useRef(null); const isToggledRef = useRef(false); const scrollTimeout = useRef(0); + const prevInternalSelectedSectionId = useRef(internalSelectedSectionId); const [selectedSubSectionId, setSelectedSubSectionId] = useState(undefined); const [headerPinned, setHeaderPinned] = useState(headerPinnedProp); @@ -106,6 +108,8 @@ const ObjectPage = forwardRef((props, ref const [toggledCollapsedHeaderWasVisible, setToggledCollapsedHeaderWasVisible] = useState(false); const sections = mode === ObjectPageMode.IconTabBar ? currentTabModeSection : children; const scrollEndHandler = useOnScrollEnd({ objectPageRef, setTabSelectId }); + // only required for IconTabBar mode + const [wasUserSectionChange, setWasUserSectionChange] = useState(false); useEffect(() => { const currentSection = @@ -113,7 +117,6 @@ const ObjectPage = forwardRef((props, ref setCurrentTabModeSection(currentSection); }, [mode, children, internalSelectedSectionId]); - const prevInternalSelectedSectionId = useRef(internalSelectedSectionId); const fireOnSelectedChangedEvent = (targetEvent, index, id, section) => { if (typeof onSelectedSectionChange === 'function' && targetEvent && prevInternalSelectedSectionId.current !== id) { onSelectedSectionChange( @@ -252,6 +255,11 @@ const ObjectPage = forwardRef((props, ref }); setTabSelectId(newSelectionSectionId); scrollEvent.current = targetEvent; + if (isMounted && mode === ObjectPageMode.Default) { + getSectionElementById(objectPageContentRef.current, false, newSelectionSectionId)?.focus({ + preventScroll: true, + }); + } fireOnSelectedChangedEvent(targetEvent, index, newSelectionSectionId, section); }; @@ -268,6 +276,9 @@ const ObjectPage = forwardRef((props, ref }; if (mode === ObjectPageMode.IconTabBar) { setInternalSelectedSectionId(selectedSectionId); + getSectionElementById(objectPageContentRef.current, false, selectedSectionId)?.focus({ + preventScroll: true, + }); // In TabBar mode the section is only rendered when selected, therefore delay firing the event until the section is available in the DOM setTimeout(fireSelectEvent); } else { @@ -594,6 +605,7 @@ const ObjectPage = forwardRef((props, ref scrollTimeout, setSelectedSubSectionId, setTabSelectId, + setWasUserSectionChange, }); const objectPageStyles: CSSProperties = { ...style, @@ -602,182 +614,207 @@ const ObjectPage = forwardRef((props, ref objectPageStyles[ObjectPageCssVariables.titleFontSize] = ThemingParameters.sapObjectHeader_Title_SnappedFontSize; } + useEffect(() => { + if (isMounted && children && mode === ObjectPageMode.Default) { + const firstSection: HTMLElement = objectPageContentRef.current.querySelector( + '[data-component-name="ObjectPageSection"]', + ); + if (firstSection) { + firstSection.tabIndex = 0; + } + } + }, [isMounted, children, mode]); + return ( -
-
+
- - {titleArea && - cloneElement(titleArea as ReactElement, { - className: clsx(titleArea?.props?.className), - onToggleHeaderContentVisibility: onTitleClick, - 'data-not-clickable': !!preserveHeaderStateOnClick, - 'data-header-content-visible': headerArea && headerCollapsed !== true, - _snappedAvatar: - (!headerArea && image) || (image && headerCollapsed === true) ? ( - - ) : null, - })} -
- {renderHeaderContentSection()} - {headerArea && titleArea && ( -
- -
- )} - {!placeholder && ( -
- , { + className: clsx(titleArea?.props?.className), + onToggleHeaderContentVisibility: onTitleClick, + 'data-not-clickable': !!preserveHeaderStateOnClick, + 'data-header-content-visible': headerArea && headerCollapsed !== true, + _snappedAvatar: + (!headerArea && image) || (image && headerCollapsed === true) ? ( + + ) : null, + })} + + {renderHeaderContentSection()} + {headerArea && titleArea && ( +
+ +
+ )} + {!placeholder && ( +
- {childrenArray.map((section, index) => { - if (!isValidElement(section) || !section.props) return null; - const subTabs = safeGetChildrenArray>( - section.props.children, - ).filter( - (subSection) => - // @ts-expect-error: if the `ObjectPageSubSection` component is passed as children, the `displayName` is available. Otherwise, the default children should be rendered w/o additional logic. - isValidElement(subSection) && subSection?.type?.displayName === 'ObjectPageSubSection', - ); - return ( - { - if (!isValidElement(item)) { - return null; + + {childrenArray.map((section, index) => { + if (!isValidElement(section) || !section.props) return null; + const subTabs = safeGetChildrenArray>( + section.props.children, + ).filter( + (subSection) => + // @ts-expect-error: if the `ObjectPageSubSection` component is passed as children, the `displayName` is available. Otherwise, the default children should be rendered w/o additional logic. + isValidElement(subSection) && subSection?.type?.displayName === 'ObjectPageSubSection', + ); + return ( + - {/*ToDo: workaround for nested tab selection*/} - - - ); - })} - > - {/*ToDo: workaround for nested tab selection*/} - - - ); - })} - -
- )} -
{ - const opNode = objectPageRef.current; - if (opNode) { - // 12px or 0.75rem margin for ui5wc border and input margins - opNode.style.scrollPaddingBlock = `${Math.ceil(12 + topHeaderHeight + TAB_CONTAINER_HEADER_HEIGHT + (!headerCollapsed && headerPinned ? headerContentHeight : 0))}px ${footerArea ? 'calc(var(--_ui5wcr-BarHeight) + 1.25rem)' : 0}`; - } - }} - onBlur={(e) => { - const opNode = objectPageRef.current; - if (opNode && !e.currentTarget.contains(e.relatedTarget as Node)) { - opNode.style.scrollPaddingBlock = '0px'; - } - }} - > + items={subTabs.map((item) => { + if (!isValidElement(item)) { + return null; + } + return ( + + {/*ToDo: workaround for nested tab selection*/} + + + ); + })} + > + {/*ToDo: workaround for nested tab selection*/} + + + ); + })} + +
+ )}
{ + if (node) { + if (mode === ObjectPageMode.IconTabBar && wasUserSectionChange) { + node.querySelector('[data-component-name="ObjectPageSection"]')?.focus({ + preventScroll: true, + }); + } + objectPageContentRef.current = node; + } + setWasUserSectionChange(false); + }} + // prevent content scroll when elements outside the content are focused + onFocus={() => { + const opNode = objectPageRef.current; + if (opNode) { + // 12px or 0.75rem margin for ui5wc border and input margins + opNode.style.scrollPaddingBlock = `${Math.ceil(12 + topHeaderHeight + TAB_CONTAINER_HEADER_HEIGHT + (!headerCollapsed && headerPinned ? headerContentHeight : 0))}px ${footerArea ? 'calc(var(--_ui5wcr-BarHeight) + 1.25rem)' : 0}`; + } + }} + onBlur={(e) => { + const opNode = objectPageRef.current; + if (opNode && !e.currentTarget.contains(e.relatedTarget as Node)) { + opNode.style.scrollPaddingBlock = '0px'; + } }} - aria-hidden="true" - /> - {placeholder ? placeholder : sections} - - {footerArea && mode === ObjectPageMode.IconTabBar && !sectionSpacer && ( - +