Skip to content

Commit ce1bd8f

Browse files
authored
fix(ui5-panel): toggle on ENTER keydown (#12794)
1 parent 7f94de3 commit ce1bd8f

File tree

2 files changed

+266
-6
lines changed

2 files changed

+266
-6
lines changed

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

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,252 @@ describe("Events", () => {
323323
});
324324
});
325325

326+
describe("Keyboard Interactions", () => {
327+
it("Enter key down expands/collapses panel", () => {
328+
cy.mount(<Panel headerText="Panel" onToggle={cy.stub().as("toggleEvent")}>
329+
<Title level={TitleLevel.H4}>Content</Title>
330+
</Panel>);
331+
332+
cy.get("[ui5-panel]")
333+
.shadow()
334+
.find(".ui5-panel-header")
335+
.as("header");
336+
337+
cy.get("[ui5-panel]")
338+
.shadow()
339+
.find(".ui5-panel-content")
340+
.as("content");
341+
342+
// Initial state - expanded
343+
cy.get("@content")
344+
.should("be.visible");
345+
346+
// Press Enter - should trigger toggle immediately
347+
cy.get("@header")
348+
.focus()
349+
.realPress("Enter");
350+
351+
// eslint-disable-next-line cypress/no-unnecessary-waiting
352+
cy.wait(50);
353+
354+
// Content should be collapsed after Enter
355+
cy.get("@content")
356+
.should("not.be.visible");
357+
358+
cy.get("@toggleEvent")
359+
.should("have.been.calledOnce");
360+
361+
// Press Enter again - should toggle back to expanded
362+
cy.get("@header")
363+
.realPress("Enter");
364+
365+
// eslint-disable-next-line cypress/no-unnecessary-waiting
366+
cy.wait(50);
367+
368+
// Content should be visible again
369+
cy.get("@content")
370+
.should("be.visible");
371+
372+
cy.get("@toggleEvent")
373+
.should("have.been.calledTwice");
374+
});
375+
376+
it("Space key with Escape cancellation prevents toggle", () => {
377+
cy.mount(<Panel headerText="Panel" onToggle={cy.stub().as("toggleEvent")}>
378+
<Title level={TitleLevel.H4}>Content</Title>
379+
</Panel>);
380+
381+
cy.get("[ui5-panel]")
382+
.shadow()
383+
.find(".ui5-panel-header")
384+
.as("header");
385+
386+
cy.get("[ui5-panel]")
387+
.shadow()
388+
.find(".ui5-panel-content")
389+
.as("content");
390+
391+
// Initial state - expanded
392+
cy.get("@content")
393+
.should("be.visible");
394+
395+
// Press and hold Space - this should set pending toggle but not execute yet
396+
cy.get("@header")
397+
.focus()
398+
.realPress(["Space", "Escape"]);
399+
400+
// Content should still be visible (toggle was canceled by Escape)
401+
cy.get("@content")
402+
.should("be.visible");
403+
404+
cy.get("@toggleEvent")
405+
.should("not.have.been.called");
406+
407+
// Verify panel is still in expanded state
408+
cy.get("[ui5-panel]")
409+
.should("not.have.attr", "collapsed");
410+
});
411+
412+
it("Space key without Escape executes toggle", () => {
413+
cy.mount(<Panel headerText="Panel" onToggle={cy.stub().as("toggleEvent")}>
414+
<Title level={TitleLevel.H4}>Content</Title>
415+
</Panel>);
416+
417+
cy.get("[ui5-panel]")
418+
.shadow()
419+
.find(".ui5-panel-header")
420+
.as("header");
421+
422+
cy.get("[ui5-panel]")
423+
.shadow()
424+
.find(".ui5-panel-content")
425+
.as("content");
426+
427+
// Initial state - expanded
428+
cy.get("@content")
429+
.should("be.visible");
430+
431+
// Press Space - should execute the toggle
432+
cy.get("@header")
433+
.focus()
434+
.realPress("Space");
435+
436+
// eslint-disable-next-line cypress/no-unnecessary-waiting
437+
cy.wait(50);
438+
439+
// Content should now be collapsed
440+
cy.get("@content")
441+
.should("not.be.visible");
442+
443+
cy.get("@toggleEvent")
444+
.should("have.been.calledOnce");
445+
446+
// Verify panel is in collapsed state
447+
cy.get("[ui5-panel]")
448+
.should("have.attr", "collapsed");
449+
});
450+
451+
it("Space key interrupted by Escape does not toggle", () => {
452+
cy.mount(<Panel headerText="Panel" onToggle={cy.stub().as("toggleEvent")}>
453+
<Title level={TitleLevel.H4}>Content</Title>
454+
</Panel>);
455+
456+
cy.get("[ui5-panel]")
457+
.shadow()
458+
.find(".ui5-panel-header")
459+
.as("header");
460+
461+
cy.get("[ui5-panel]")
462+
.shadow()
463+
.find(".ui5-panel-content")
464+
.as("content");
465+
466+
// Test the Space + Escape cancellation behavior
467+
cy.get("@header")
468+
.focus()
469+
.realPress(["Space", "Escape"]);
470+
471+
// Should not have toggled because Escape canceled the Space action
472+
cy.get("@content")
473+
.should("be.visible");
474+
475+
cy.get("@toggleEvent")
476+
.should("not.have.been.called");
477+
});
478+
479+
it("Fixed panel (should not toggle)", () => {
480+
cy.mount(<Panel headerText="Fixed Panel" fixed={true} onToggle={cy.stub().as("toggleEvent")}>
481+
<Title level={TitleLevel.H4}>Content</Title>
482+
</Panel>);
483+
484+
cy.get("[ui5-panel]")
485+
.shadow()
486+
.find(".ui5-panel-header")
487+
.as("header");
488+
489+
cy.get("[ui5-panel]")
490+
.shadow()
491+
.find(".ui5-panel-content")
492+
.as("content");
493+
494+
// Content should be visible
495+
cy.get("@content")
496+
.should("be.visible");
497+
498+
// Try Enter - should not toggle fixed panel
499+
cy.get("@header")
500+
.focus()
501+
.realPress("Enter");
502+
503+
cy.get("@content")
504+
.should("be.visible");
505+
506+
cy.get("@toggleEvent")
507+
.should("not.have.been.called");
508+
509+
// Try Space - should not toggle fixed panel
510+
cy.get("@header")
511+
.realPress("Space");
512+
513+
cy.get("@content")
514+
.should("be.visible");
515+
516+
cy.get("@toggleEvent")
517+
.should("not.have.been.called");
518+
});
519+
520+
it("Custom header (only button should work)", () => {
521+
cy.mount(<Panel onToggle={cy.stub().as("toggleEvent")}>
522+
<div slot="header">
523+
<Title level={TitleLevel.H2}>Custom Header</Title>
524+
</div>
525+
<Title level={TitleLevel.H3}>Content</Title>
526+
</Panel>);
527+
528+
cy.get("[ui5-panel]")
529+
.shadow()
530+
.find(".ui5-panel-header")
531+
.as("header");
532+
533+
cy.get("[ui5-panel]")
534+
.shadow()
535+
.find(".ui5-panel-header-button")
536+
.as("toggleButton");
537+
538+
cy.get("[ui5-panel]")
539+
.shadow()
540+
.find(".ui5-panel-content")
541+
.as("content");
542+
543+
// Enter on custom header area should not toggle
544+
cy.get("@header")
545+
.focus()
546+
.realPress("Enter");
547+
548+
cy.get("@content")
549+
.should("be.visible");
550+
551+
cy.get("@toggleEvent")
552+
.should("not.have.been.called");
553+
554+
// Enter on toggle button should work
555+
cy.get("@toggleButton")
556+
.shadow()
557+
.find(".ui5-button-root")
558+
.focus()
559+
.realPress("Enter");
560+
561+
// eslint-disable-next-line cypress/no-unnecessary-waiting
562+
cy.wait(50);
563+
564+
cy.get("@content")
565+
.should("not.be.visible");
566+
567+
cy.get("@toggleEvent")
568+
.should("have.been.calledOnce");
569+
});
570+
});
571+
326572
describe("Accessibility", () => {
327573
it("Aria attributes on default header", () => {
328574
cy.mount(<Panel headerText="Panel" headerLevel={TitleLevel.H3}>

packages/main/src/Panel.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import slot from "@ui5/webcomponents-base/dist/decorators/slot.js";
66
import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js";
77
import slideDown from "@ui5/webcomponents-base/dist/animations/slideDown.js";
88
import slideUp from "@ui5/webcomponents-base/dist/animations/slideUp.js";
9-
import { isSpace, isEnter } from "@ui5/webcomponents-base/dist/Keys.js";
9+
import { isSpace, isEnter, isEscape } from "@ui5/webcomponents-base/dist/Keys.js";
1010
import AnimationMode from "@ui5/webcomponents-base/dist/types/AnimationMode.js";
1111
import { getAnimationMode } from "@ui5/webcomponents-base/dist/config/AnimationMode.js";
1212
import i18n from "@ui5/webcomponents-base/dist/decorators/i18n.js";
@@ -170,8 +170,8 @@ class Panel extends UI5Element {
170170
* @public
171171
* @since 1.16.0-rc.1
172172
*/
173-
@property({ type: Boolean })
174-
stickyHeader = false;
173+
@property({ type: Boolean })
174+
stickyHeader = false;
175175

176176
/**
177177
* When set to `true`, the `accessibleName` property will be
@@ -195,6 +195,9 @@ class Panel extends UI5Element {
195195
@property({ type: Boolean, noAttribute: true })
196196
_animationRunning = false;
197197

198+
@property({ type: Boolean, noAttribute: true })
199+
_pendingToggle = false;
200+
198201
/**
199202
* Defines the component header area.
200203
*
@@ -248,11 +251,18 @@ class Panel extends UI5Element {
248251
}
249252

250253
if (isEnter(e)) {
251-
e.preventDefault();
254+
this._toggleOpen();
252255
}
253256

254257
if (isSpace(e)) {
255258
e.preventDefault();
259+
this._pendingToggle = true;
260+
}
261+
262+
// Cancel toggle if Escape is pressed
263+
if (isEscape(e) && this._pendingToggle) {
264+
e.preventDefault();
265+
this._pendingToggle = false;
256266
}
257267
}
258268

@@ -262,11 +272,15 @@ class Panel extends UI5Element {
262272
}
263273

264274
if (isEnter(e)) {
265-
this._toggleOpen();
275+
e.preventDefault();
266276
}
267277

268278
if (isSpace(e)) {
269-
this._toggleOpen();
279+
// Only toggle if space was pressed and escape wasn't pressed to cancel
280+
if (this._pendingToggle) {
281+
this._toggleOpen();
282+
}
283+
this._pendingToggle = false;
270284
}
271285
}
272286

0 commit comments

Comments
 (0)