Skip to content

Commit 718a93c

Browse files
feat(ui5-panel): focus for scrollable panel content (#13501)
1 parent 6b52d85 commit 718a93c

6 files changed

Lines changed: 466 additions & 7 deletions

File tree

packages/main/cypress/specs/Panel.cy.tsx

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Button from "../../src/Button.js";
12
import Label from "../../src/Label.js";
23
import Panel from "../../src/Panel.js";
34
import Title from "../../src/Title.js";
@@ -725,3 +726,139 @@ describe("Accessibility", () => {
725726
.should("have.attr", "aria-label", accessibleNamePanel);
726727
});
727728
});
729+
730+
describe("Scrollable Content Focus", () => {
731+
function addPageStyles(styles: string) {
732+
cy.document().then((doc) => {
733+
const style = doc.createElement("style");
734+
style.id = "panel-focus-styles";
735+
style.innerHTML = styles;
736+
doc.head.appendChild(style);
737+
});
738+
}
739+
function clearPageStyles() {
740+
cy.window()
741+
.then($el => {
742+
const styleTag = $el.document.head.querySelector("style[id='panel-focus-styles']");
743+
styleTag?.remove();
744+
});
745+
}
746+
747+
it("Scrollable content with no focusable children is focusable", () => {
748+
addPageStyles(`
749+
#panel-scroll::part(content) {
750+
max-height: 50px;
751+
}
752+
`);
753+
754+
const longText = "Lorem ipsum dolor sit amet. ".repeat(20);
755+
756+
cy.mount(
757+
<Panel headerText="Scrollable Panel" id="panel-scroll">
758+
<div>{longText}</div>
759+
</Panel>
760+
);
761+
762+
cy.get("[ui5-panel]")
763+
.shadow()
764+
.find(".ui5-panel-content")
765+
.as("content");
766+
767+
cy.get("[ui5-panel]")
768+
.shadow()
769+
.find(".ui5-panel-content-wrapper")
770+
.as("wrapper");
771+
772+
cy.wait(100);
773+
774+
cy.get("@content")
775+
.should($el => {
776+
expect($el[0].scrollHeight).to.be.greaterThan($el[0].clientHeight);
777+
});
778+
779+
cy.get("@content")
780+
.should("have.attr", "tabindex", "0");
781+
782+
cy.get("@wrapper")
783+
.should("have.class", "ui5-panel-content-focusable");
784+
785+
cy.get("[ui5-panel]")
786+
.shadow()
787+
.find(".ui5-panel-header")
788+
.focus()
789+
.realPress("Tab");
790+
791+
cy.get("@content")
792+
.should("be.focused");
793+
794+
clearPageStyles();
795+
});
796+
797+
it("Scrollable content with focusable children is NOT focusable", () => {
798+
addPageStyles(`
799+
#panel-button::part(content) {
800+
max-height: 50px;
801+
}
802+
`);
803+
804+
const longText = "Lorem ipsum dolor sit amet. ".repeat(10);
805+
806+
cy.mount(
807+
<Panel headerText="Panel with Button" id="panel-button">
808+
<div>{longText}</div>
809+
<Button>Click me</Button>
810+
<div>{longText}</div>
811+
</Panel>
812+
);
813+
814+
cy.get("[ui5-panel]")
815+
.shadow()
816+
.find(".ui5-panel-content")
817+
.as("content");
818+
819+
cy.get("[ui5-panel]")
820+
.shadow()
821+
.find(".ui5-panel-content-wrapper")
822+
.as("wrapper");
823+
824+
cy.wait(100);
825+
826+
cy.get("@content")
827+
.should("not.have.attr", "tabindex");
828+
829+
cy.get("@wrapper")
830+
.should("not.have.class", "ui5-panel-content-focusable");
831+
832+
cy.get("[ui5-panel]")
833+
.shadow()
834+
.find(".ui5-panel-header")
835+
.focus()
836+
.realPress("Tab");
837+
838+
cy.get("[ui5-button]")
839+
.should("be.focused");
840+
841+
clearPageStyles();
842+
});
843+
844+
it("Non-scrollable content is NOT focusable", () => {
845+
cy.mount(
846+
<Panel headerText="Short Content Panel">
847+
<Label>Short text</Label>
848+
</Panel>
849+
);
850+
851+
cy.get("[ui5-panel]")
852+
.shadow()
853+
.find(".ui5-panel-content")
854+
.as("content");
855+
856+
cy.get("@content")
857+
.should($el => {
858+
expect($el[0].scrollHeight).to.be.lte($el[0].clientHeight);
859+
});
860+
861+
cy.get("@content")
862+
.should("not.have.attr", "tabindex");
863+
});
864+
});

packages/main/src/Panel.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { supportsTouch } from "@ui5/webcomponents-base/dist/Device.js";
1414
import i18n from "@ui5/webcomponents-base/dist/decorators/i18n.js";
1515
import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js";
1616
import type { UI5CustomEvent } from "@ui5/webcomponents-base";
17+
import { getTabbableElements } from "@ui5/webcomponents-base/dist/util/TabbableElements.js";
1718
import type TitleLevel from "./types/TitleLevel.js";
1819
import type Button from "./Button.js";
1920
import type PanelAccessibleRole from "./types/PanelAccessibleRole.js";
@@ -203,6 +204,14 @@ class Panel extends UI5Element {
203204
@property({ type: Boolean })
204205
_touched = false;
205206

207+
/**
208+
* Indicates whether the content area should be focusable.
209+
* This is true when content is scrollable and has no focusable children.
210+
* @private
211+
*/
212+
@property({ type: Boolean, noAttribute: true })
213+
_contentFocusable = false;
214+
206215
/**
207216
* Defines the component header area.
208217
*
@@ -224,6 +233,10 @@ class Panel extends UI5Element {
224233
this._hasHeader = !!this.header.length;
225234
}
226235

236+
onAfterRendering() {
237+
this._updateContentFocusable();
238+
}
239+
227240
shouldToggle(element: HTMLElement): boolean {
228241
const customContent = this.header.length;
229242
if (customContent) {
@@ -335,6 +348,50 @@ class Panel extends UI5Element {
335348
return target.classList.contains("sapMPanelWrappingDiv");
336349
}
337350

351+
/**
352+
* Updates the focusability of the content area.
353+
* Content becomes focusable when:
354+
* - Panel is expanded (not collapsed)
355+
* - Content is scrollable (scrollHeight > clientHeight or scrollWidth > clientWidth)
356+
* - No focusable children exist inside
357+
* @private
358+
*/
359+
_updateContentFocusable() {
360+
// Not focusable when collapsed
361+
if (this.collapsed) {
362+
this._contentFocusable = false;
363+
return;
364+
}
365+
366+
const contentDom = this.shadowRoot?.querySelector(".ui5-panel-content") as HTMLElement | null;
367+
if (!contentDom) {
368+
this._contentFocusable = false;
369+
return;
370+
}
371+
372+
// Check if scrollable (vertical OR horizontal)
373+
const isScrollable = contentDom.scrollHeight > contentDom.clientHeight
374+
|| contentDom.scrollWidth > contentDom.clientWidth;
375+
376+
if (!isScrollable) {
377+
this._contentFocusable = false;
378+
return;
379+
}
380+
381+
// Check for focusable children (synchronous)
382+
const tabbables = getTabbableElements(contentDom);
383+
this._contentFocusable = tabbables.length === 0;
384+
}
385+
386+
/**
387+
* Returns the tabindex for the content area.
388+
* Returns 0 when content should be focusable, undefined otherwise (removes attribute).
389+
* @private
390+
*/
391+
get _contentTabIndex(): number | undefined {
392+
return this._contentFocusable ? 0 : undefined;
393+
}
394+
338395
get toggleButtonTitle() {
339396
return Panel.i18nBundle.getText(PANEL_ICON);
340397
}

packages/main/src/PanelTemplate.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,15 +89,22 @@ export default function PanelTemplate(this: Panel) {
8989

9090
{/* content area */}
9191
<div
92-
class="ui5-panel-content"
93-
id={ `${this._id}-content` }
94-
tabindex={ -1 }
92+
class={{
93+
"ui5-panel-content-wrapper": true,
94+
"ui5-panel-content-focusable": this._contentFocusable,
95+
}}
9596
style={{
9697
display: this._contentExpanded ? "block" : "none",
9798
}}
98-
part="content"
9999
>
100-
<slot></slot>
100+
<div
101+
class="ui5-panel-content"
102+
id={ `${this._id}-content` }
103+
tabindex={ this._contentTabIndex }
104+
part="content"
105+
>
106+
<slot></slot>
107+
</div>
101108
</div>
102109
</div>
103110
</>);

packages/main/src/themes/Panel.css

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,30 @@
9191
white-space: nowrap;
9292
}
9393

94-
.ui5-panel-content {
94+
.ui5-panel-content-wrapper {
95+
overflow: hidden;
9596
padding: var(--_ui5_panel_content_padding);
9697
background-color: var(--sapGroup_ContentBackground);
97-
outline: none;
9898
border-bottom-left-radius: var(--_ui5_panel_border_radius);
9999
border-bottom-right-radius: var(--_ui5_panel_border_radius);
100+
flex: 1;
101+
min-height: 0;
102+
box-sizing: border-box;
103+
}
104+
105+
.ui5-panel-content-wrapper.ui5-panel-content-focusable:focus-within {
106+
outline: var(--_ui5_panel_focus_border);
107+
outline-offset: var(--_ui5_panel_content_focus_offset);
108+
}
109+
110+
.ui5-panel-content {
111+
height: 100%;
100112
overflow: auto;
113+
outline: none;
114+
}
115+
116+
.ui5-panel-content:focus {
117+
outline: none;
101118
}
102119

103120
.ui5-panel-header-button-root {

packages/main/src/themes/base/Panel-parameters.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
--_ui5_panel_focus_offset: 1px;
1414
--_ui5_panel_focus_bottom_offset: var(--_ui5_panel_focus_offset);
1515
--_ui5_panel_content_padding: 0.625rem 1rem 1.375rem 1rem;
16+
--_ui5_panel_content_focus_offset: -0.1875rem;
1617
--_ui5_panel_header_background_color: var(--sapBackgroundColor);
1718
}
1819

0 commit comments

Comments
 (0)