Skip to content

Commit 8f2e1e7

Browse files
committed
feat(ObjectPage): enable keyboard-navigation for sections
1 parent 4b24954 commit 8f2e1e7

File tree

6 files changed

+157
-12
lines changed

6 files changed

+157
-12
lines changed

packages/main/src/components/ObjectPage/ObjectPage.module.css

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,7 @@
130130

131131
@container (max-width: 599px) {
132132
.header,
133-
.headerContainer,
134-
.content {
133+
.headerContainer {
135134
padding-inline: 1rem;
136135
}
137136

@@ -142,8 +141,7 @@
142141

143142
@container (min-width: 600px) and (max-width: 1439px) {
144143
.header,
145-
.headerContainer,
146-
.content {
144+
.headerContainer {
147145
padding-inline: 2rem;
148146
}
149147

@@ -154,8 +152,7 @@
154152

155153
@container (min-width: 1440px) {
156154
.header,
157-
.headerContainer,
158-
.content {
155+
.headerContainer {
159156
padding-inline: 3rem;
160157
}
161158

packages/main/src/components/ObjectPage/ObjectPageUtils.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ReactElement } from 'react';
1+
import type { ReactElement, KeyboardEvent } from 'react';
22
import { isValidElement } from 'react';
33
import { safeGetChildrenArray } from '../../internal/safeGetChildrenArray.js';
44
import type { ObjectPageSectionPropTypes } from '../ObjectPageSection/index.js';
@@ -17,3 +17,39 @@ export const getSectionElementById = (objectPage: HTMLDivElement, isSubSection:
1717
`#${isSubSection ? 'ObjectPageSubSection' : 'ObjectPageSection'}-${CSS.escape(id)}`,
1818
);
1919
};
20+
21+
interface NavigateSectionParam {
22+
e: KeyboardEvent<HTMLDivElement>;
23+
onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => void;
24+
componentName: 'ObjectPageSection' | 'ObjectPageSubSection';
25+
}
26+
27+
export function navigateSections({ e, onKeyDown, componentName }: NavigateSectionParam) {
28+
if (typeof onKeyDown === 'function') {
29+
onKeyDown(e);
30+
}
31+
if (e.currentTarget !== e.target) {
32+
return;
33+
}
34+
35+
const nextSibling = e.currentTarget.nextElementSibling as HTMLElement;
36+
const prevSibling = e.currentTarget.previousElementSibling as HTMLElement;
37+
if ((e.key === 'ArrowDown' || e.key === 'ArrowRight') && nextSibling.dataset.componentName === componentName) {
38+
e.preventDefault();
39+
e.currentTarget.tabIndex = -1;
40+
nextSibling.tabIndex = 0;
41+
nextSibling.focus({ preventScroll: true });
42+
nextSibling.scrollIntoView({ behavior: 'instant', block: 'start' });
43+
}
44+
45+
if ((e.key === 'ArrowUp' || e.key === 'ArrowLeft') && prevSibling.dataset.componentName === componentName) {
46+
e.preventDefault();
47+
e.currentTarget.tabIndex = -1;
48+
prevSibling.tabIndex = 0;
49+
prevSibling.focus({ preventScroll: true });
50+
prevSibling.scrollIntoView({
51+
behavior: 'instant',
52+
block: 'start',
53+
});
54+
}
55+
}

packages/main/src/components/ObjectPage/index.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,9 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref
252252
});
253253
setTabSelectId(newSelectionSectionId);
254254
scrollEvent.current = targetEvent;
255+
if (isMounted) {
256+
getSectionElementById(objectPageContentRef.current, false, newSelectionSectionId)?.focus({ preventScroll: true });
257+
}
255258
fireOnSelectedChangedEvent(targetEvent, index, newSelectionSectionId, section);
256259
};
257260

@@ -602,6 +605,17 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref
602605
objectPageStyles[ObjectPageCssVariables.titleFontSize] = ThemingParameters.sapObjectHeader_Title_SnappedFontSize;
603606
}
604607

608+
useEffect(() => {
609+
if (isMounted && children) {
610+
const firstSection: HTMLElement = objectPageContentRef.current.querySelector(
611+
'[data-component-name="ObjectPageSection"]',
612+
);
613+
if (firstSection) {
614+
firstSection.tabIndex = 0;
615+
}
616+
}
617+
}, [isMounted, children]);
618+
605619
return (
606620
<div
607621
ref={componentRef}

packages/main/src/components/ObjectPageSection/ObjectPageSection.module.css

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
1+
.section {
2+
box-sizing: border-box;
3+
}
4+
15
.section [data-component-name='ObjectPageSubSection']:not(:first-child) {
26
padding-block-start: 0.5rem;
37
}
48

9+
.section:focus {
10+
outline: var(--sapContent_FocusWidth) var(--sapContent_FocusStyle) var(--sapContent_FocusColor);
11+
outline-offset: calc(-1 * var(--sapContent_FocusWidth));
12+
}
13+
514
.headerContainer {
615
padding-block: 0.5rem;
716
color: var(--sapGroup_TitleTextColor);
@@ -54,3 +63,21 @@
5463
height: 100%;
5564
box-sizing: border-box;
5665
}
66+
67+
@container (max-width: 599px) {
68+
.section {
69+
padding-inline: 1rem;
70+
}
71+
}
72+
73+
@container (min-width: 600px) and (max-width: 1439px) {
74+
.section {
75+
padding-inline: 2rem;
76+
}
77+
}
78+
79+
@container (min-width: 1440px) {
80+
.section {
81+
padding-inline: 3rem;
82+
}
83+
}

packages/main/src/components/ObjectPageSection/index.tsx

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
import type TitleLevel from '@ui5/webcomponents/dist/types/TitleLevel.js';
44
import { useStylesheet } from '@ui5/webcomponents-react-base';
55
import { clsx } from 'clsx';
6-
import type { ReactNode } from 'react';
7-
import { forwardRef } from 'react';
6+
import type { ReactNode, FocusEventHandler, KeyboardEventHandler } from 'react';
7+
import { Children, isValidElement, forwardRef } from 'react';
88
import type { CommonProps } from '../../types/index.js';
9+
import { navigateSections } from '../ObjectPage/ObjectPageUtils.js';
910
import { classNames, styleData } from './ObjectPageSection.module.css.js';
1011

1112
export interface ObjectPageSectionPropTypes extends CommonProps {
@@ -73,22 +74,88 @@ const ObjectPageSection = forwardRef<HTMLElement, ObjectPageSectionPropTypes>((p
7374
header,
7475
...rest
7576
} = props;
76-
7777
useStylesheet(styleData, ObjectPageSection.displayName);
78-
7978
const htmlId = `ObjectPageSection-${id}`;
80-
8179
const titleClasses = clsx(classNames.title, titleTextUppercase && classNames.uppercase);
8280

81+
const handleFocus: FocusEventHandler<HTMLElement> = (e) => {
82+
if (typeof props.onFocus === 'function') {
83+
props.onFocus(e);
84+
}
85+
const hasSubSection = Children.toArray(children).some(
86+
// @ts-expect-error: if type is string, then it's not a subcomponent
87+
(child) => isValidElement(child) && child.type?.displayName === 'ObjectPageSubSection',
88+
);
89+
if (hasSubSection && e.target === e.currentTarget) {
90+
const opSubSection: HTMLElement = e.currentTarget.querySelector('[data-component-name="ObjectPageSubSection"]');
91+
if (opSubSection) {
92+
opSubSection.tabIndex = 0;
93+
}
94+
}
95+
};
96+
97+
const handleBlur: FocusEventHandler<HTMLElement> = (e) => {
98+
if (typeof props.onBlur === 'function') {
99+
props.onBlur(e);
100+
}
101+
const hasSubSection = Children.toArray(children).some(
102+
// @ts-expect-error: if type is string, then it's not a subcomponent
103+
(child) => isValidElement(child) && child.type?.displayName === 'ObjectPageSubSection',
104+
);
105+
if (hasSubSection && e.target === e.currentTarget) {
106+
const allSubSections: NodeListOf<HTMLElement> = e.currentTarget.querySelectorAll(
107+
'[data-component-name="ObjectPageSubSection"]',
108+
);
109+
allSubSections.forEach((subSection) => {
110+
subSection.tabIndex = -1;
111+
});
112+
}
113+
};
114+
115+
const handleKeyDown: KeyboardEventHandler<HTMLDivElement> = (e) => {
116+
navigateSections({ e, onKeyDown: props.onKeyDown, componentName: 'ObjectPageSection' });
117+
const target = e.currentTarget as HTMLElement;
118+
if (
119+
(e.key === 'ArrowDown' || e.key === 'ArrowRight') &&
120+
(target.nextElementSibling as HTMLElement).dataset.componentName === 'ObjectPageSection'
121+
) {
122+
e.preventDefault();
123+
// scroll 12px so section is noticed as selected
124+
requestAnimationFrame(() => {
125+
target.parentElement.parentElement.scrollBy(0, 12);
126+
const isFirstSection =
127+
(target.previousElementSibling as HTMLElement).dataset.componentName !== 'ObjectPageSection';
128+
// header collapse leads to loose scrolling - this fallback makes sure the second section is marked as selected
129+
if (isFirstSection) {
130+
target.parentElement.parentElement.scrollBy(0, 14);
131+
}
132+
});
133+
}
134+
if (
135+
(e.key === 'ArrowUp' || e.key === 'ArrowLeft') &&
136+
(target.previousElementSibling as HTMLElement).dataset.componentName === 'ObjectPageSection'
137+
) {
138+
e.preventDefault();
139+
// scroll 12px so section is noticed as selected
140+
requestAnimationFrame(() => {
141+
target.parentElement.parentElement.scrollBy(0, 12);
142+
});
143+
}
144+
};
145+
83146
return (
84147
<section
85148
ref={ref}
86149
role="region"
87150
className={clsx(classNames.section, wrapTitleText && classNames.wrap, className)}
88151
style={style}
152+
tabIndex={-1}
89153
{...rest}
90154
id={htmlId}
91155
data-component-name="ObjectPageSection"
156+
onFocus={handleFocus}
157+
onBlur={handleBlur}
158+
onKeyDown={handleKeyDown}
92159
>
93160
{!!header && <div className={classNames.headerContainer}>{header}</div>}
94161
{!hideTitleText && (

packages/main/src/components/ObjectPageSubSection/index.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { forwardRef } from 'react';
88
import { FlexBoxAlignItems, FlexBoxDirection, FlexBoxJustifyContent } from '../../enums/index.js';
99
import type { CommonProps } from '../../types/index.js';
1010
import { FlexBox } from '../FlexBox/index.js';
11+
import { navigateSections } from '../ObjectPage/ObjectPageUtils.js';
1112
import { classNames, styleData } from './ObjectPageSubSection.module.css.js';
1213

1314
export interface ObjectPageSubSectionPropTypes extends CommonProps {
@@ -83,6 +84,9 @@ const ObjectPageSubSection = forwardRef<HTMLDivElement, ObjectPageSubSectionProp
8384
style={style}
8485
tabIndex={-1}
8586
{...rest}
87+
onKeyDown={(e) => {
88+
navigateSections({ e, onKeyDown: props.onKeyDown, componentName: 'ObjectPageSubSection' });
89+
}}
8690
className={subSectionClassName}
8791
id={htmlId}
8892
data-component-name="ObjectPageSubSection"

0 commit comments

Comments
 (0)