From 91235cbb32422fbf8126ddb438105853d5adf369 Mon Sep 17 00:00:00 2001 From: Gabriel Belgamo <19699724+belgamo@users.noreply.github.com> Date: Tue, 11 Mar 2025 17:17:33 +0000 Subject: [PATCH] Fixes dropdown closing on tab key (#2371) * fix(sl-menu): tabbing closes the dropdown when it's open * fix(arbitrary content): tabbing closes the dropdown when it's open * feat: add pr suggestions --- src/components/dropdown/dropdown.component.ts | 20 +++- src/components/dropdown/dropdown.test.ts | 97 +++++++++++++++++++ 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/src/components/dropdown/dropdown.component.ts b/src/components/dropdown/dropdown.component.ts index c022860b..4dea944c 100644 --- a/src/components/dropdown/dropdown.component.ts +++ b/src/components/dropdown/dropdown.component.ts @@ -1,6 +1,7 @@ import { animateTo, stopAnimations } from '../../internal/animate.js'; import { classMap } from 'lit/directives/class-map.js'; import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js'; +import { getDeepestActiveElement } from '../../internal/active-elements.js'; import { getTabbableBoundary } from '../../internal/tabbable.js'; import { html } from 'lit'; import { ifDefined } from 'lit/directives/if-defined.js'; @@ -175,6 +176,20 @@ export default class SlDropdown extends ShoelaceElement { return; } + const computeClosestContaining = (element: Element | null | undefined, tagName: string): Element | null => { + if (!element) return null; + + const closest = element.closest(tagName); + if (closest) return closest; + + const rootNode = element.getRootNode(); + if (rootNode instanceof ShadowRoot) { + return computeClosestContaining(rootNode.host, tagName); + } + + return null; + }; + // Tabbing outside of the containing element closes the panel // // If the dropdown is used within a shadow DOM, we need to obtain the activeElement within that shadowRoot, @@ -182,12 +197,13 @@ export default class SlDropdown extends ShoelaceElement { setTimeout(() => { const activeElement = this.containingElement?.getRootNode() instanceof ShadowRoot - ? document.activeElement?.shadowRoot?.activeElement + ? getDeepestActiveElement() : document.activeElement; if ( !this.containingElement || - activeElement?.closest(this.containingElement.tagName.toLowerCase()) !== this.containingElement + computeClosestContaining(activeElement, this.containingElement.tagName.toLowerCase()) !== + this.containingElement ) { this.hide(); } diff --git a/src/components/dropdown/dropdown.test.ts b/src/components/dropdown/dropdown.test.ts index 34a2fecb..27dcc24a 100644 --- a/src/components/dropdown/dropdown.test.ts +++ b/src/components/dropdown/dropdown.test.ts @@ -1,6 +1,8 @@ import '../../../dist/shoelace.js'; import { clickOnElement } from '../../internal/test.js'; +import { customElement } from 'lit/decorators.js'; import { expect, fixture, html, waitUntil } from '@open-wc/testing'; +import { LitElement } from 'lit'; import { sendKeys, sendMouse } from '@web/test-runner-commands'; import sinon from 'sinon'; import type SlDropdown from './dropdown.js'; @@ -354,4 +356,99 @@ describe('', () => { expect(el.open).to.be.false; }); + + describe('when a sl-menu is provided and the dropdown is opened', () => { + before(() => { + @customElement('custom-wrapper') + class Wrapper extends LitElement { + render() { + return html``; + } + } + // eslint-disable-next-line chai-friendly/no-unused-expressions + Wrapper; + + @customElement('nested-dropdown') + class NestedDropdown extends LitElement { + render() { + return html` + + Toggle + + Item 1 + + + `; + } + } + // eslint-disable-next-line chai-friendly/no-unused-expressions + NestedDropdown; + }); + + it('should remain open on tab key', async () => { + const el = await fixture(html``); + + const dropdown = el.shadowRoot!.querySelector('nested-dropdown')!.shadowRoot!.querySelector('sl-dropdown')!; + + const trigger = dropdown.querySelector('sl-button')!; + + trigger.focus(); + await dropdown.updateComplete; + await sendKeys({ press: 'Enter' }); + await dropdown.updateComplete; + await sendKeys({ press: 'Tab' }); + await dropdown.updateComplete; + + expect(dropdown.open).to.be.true; + }); + }); + + describe('when arbitrary content is provided and the dropdown is opened', () => { + before(() => { + @customElement('custom-wrapper-arbitrary') + class WrapperArbitrary extends LitElement { + render() { + return html``; + } + } + // eslint-disable-next-line chai-friendly/no-unused-expressions + WrapperArbitrary; + + @customElement('nested-dropdown-arbitrary') + class NestedDropdownArbitrary extends LitElement { + render() { + return html` + + Toggle + + + `; + } + } + // eslint-disable-next-line chai-friendly/no-unused-expressions + NestedDropdownArbitrary; + }); + + it('should remain open on tab key', async () => { + const el = await fixture(html``); + + const dropdown = el + .shadowRoot!.querySelector('nested-dropdown-arbitrary')! + .shadowRoot!.querySelector('sl-dropdown')!; + + const trigger = dropdown.querySelector('sl-button')!; + + trigger.focus(); + await dropdown.updateComplete; + await sendKeys({ press: 'Enter' }); + await dropdown.updateComplete; + await sendKeys({ press: 'Tab' }); + await dropdown.updateComplete; + + expect(dropdown.open).to.be.true; + }); + }); });