diff --git a/.changeset/two-goats-press.md b/.changeset/two-goats-press.md new file mode 100644 index 0000000000..dcafe9a75e --- /dev/null +++ b/.changeset/two-goats-press.md @@ -0,0 +1,6 @@ +--- +"@stackoverflow/stacks": minor +"@stackoverflow/stacks-svelte": minor +--- + +Fix popover focus-leave dismissal diff --git a/packages/stacks-classic/lib/components/popover/popover.test.ts b/packages/stacks-classic/lib/components/popover/popover.test.ts new file mode 100644 index 0000000000..b84f1a3854 --- /dev/null +++ b/packages/stacks-classic/lib/components/popover/popover.test.ts @@ -0,0 +1,225 @@ +import { html, fixture, expect } from "@open-wc/testing"; +import { screen, waitFor } from "@testing-library/dom"; +import userEvent from "@testing-library/user-event"; +import "../../index"; + +const user = userEvent.setup(); + +const createPopover = ({ + content = html`View more`, + hideOnOutsideClick = "always", + renderOutsideButton = true, + role = "menu", +}: { + content?: ReturnType; + hideOnOutsideClick?: string; + renderOutsideButton?: boolean; + role?: string; +} = {}) => html` + +
+
${content}
+
+ ${renderOutsideButton + ? html`` + : null} +`; + +describe("popover", () => { + it('should set aria-expanded="true" when shown and "false" when hidden', async () => { + await fixture(createPopover({ renderOutsideButton: false })); + + const trigger = screen.getByTestId("popover-trigger"); + const popover = screen.getByTestId("popover"); + + await waitFor(() => + expect(trigger).to.have.attribute("aria-expanded", "false") + ); + + await user.click(trigger); + + await waitFor(() => expect(popover).to.have.class("is-visible")); + expect(trigger).to.have.attribute("aria-expanded", "true"); + + await user.click(trigger); + + await waitFor(() => expect(popover).not.to.have.class("is-visible")); + expect(trigger).to.have.attribute("aria-expanded", "false"); + }); + + it("should stay open when focus moves from the trigger into the popover", async () => { + await fixture(createPopover()); + + const trigger = screen.getByTestId("popover-trigger"); + const popover = screen.getByTestId("popover"); + const link = screen.getByTestId("popover-link"); + + await user.click(trigger); + await waitFor(() => expect(popover).to.have.class("is-visible")); + + await user.tab(); + + expect(link).to.have.focus; + expect(popover).to.have.class("is-visible"); + expect(trigger).to.have.attribute("aria-expanded", "true"); + }); + + it("should stay open when focus moves from the popover back to the trigger", async () => { + await fixture(createPopover()); + + const trigger = screen.getByTestId("popover-trigger"); + const popover = screen.getByTestId("popover"); + const link = screen.getByTestId("popover-link"); + + await user.click(trigger); + await waitFor(() => expect(popover).to.have.class("is-visible")); + + await user.tab(); + expect(link).to.have.focus; + + await user.tab({ shift: true }); + + expect(trigger).to.have.focus; + expect(popover).to.have.class("is-visible"); + expect(trigger).to.have.attribute("aria-expanded", "true"); + }); + + it("should hide when focus moves from the popover to an outside button", async () => { + await fixture(createPopover()); + + const trigger = screen.getByTestId("popover-trigger"); + const popover = screen.getByTestId("popover"); + const outsideButton = screen.getByTestId("outside-button"); + + await user.click(trigger); + await waitFor(() => expect(popover).to.have.class("is-visible")); + + await user.tab(); + await user.tab(); + + expect(outsideButton).to.have.focus; + await waitFor(() => expect(popover).not.to.have.class("is-visible")); + expect(trigger).to.have.attribute("aria-expanded", "false"); + }); + + it("should hide when focus leaves a popover containing a menu", async () => { + await fixture( + createPopover({ + content: html` + + `, + role: "dialog", + }) + ); + + const trigger = screen.getByTestId("popover-trigger"); + const popover = screen.getByTestId("popover"); + const outsideButton = screen.getByTestId("outside-button"); + + await user.click(trigger); + await waitFor(() => expect(popover).to.have.class("is-visible")); + + await user.tab(); + await user.tab(); + + expect(outsideButton).to.have.focus; + await waitFor(() => expect(popover).not.to.have.class("is-visible")); + expect(trigger).to.have.attribute("aria-expanded", "false"); + }); + + it("should stay open when focus moves outside a non-menu popover", async () => { + await fixture(createPopover({ role: "dialog" })); + + const trigger = screen.getByTestId("popover-trigger"); + const popover = screen.getByTestId("popover"); + const outsideButton = screen.getByTestId("outside-button"); + + await user.click(trigger); + await waitFor(() => expect(popover).to.have.class("is-visible")); + + await user.tab(); + await user.tab(); + + expect(outsideButton).to.have.focus; + expect(popover).to.have.class("is-visible"); + expect(trigger).to.have.attribute("aria-expanded", "true"); + }); + + it("should stay open when focus leaves a menu popover that disables auto-dismissal", async () => { + await fixture(createPopover({ hideOnOutsideClick: "never" })); + + const trigger = screen.getByTestId("popover-trigger"); + const popover = screen.getByTestId("popover"); + const outsideButton = screen.getByTestId("outside-button"); + + await user.click(trigger); + await waitFor(() => expect(popover).to.have.class("is-visible")); + + await user.tab(); + await user.tab(); + + expect(outsideButton).to.have.focus; + expect(popover).to.have.class("is-visible"); + expect(trigger).to.have.attribute("aria-expanded", "true"); + }); + + it("should hide when focus moves from the trigger to an outside button", async () => { + await fixture(createPopover({ content: html`View more` })); + + const trigger = screen.getByTestId("popover-trigger"); + const popover = screen.getByTestId("popover"); + const outsideButton = screen.getByTestId("outside-button"); + + await user.click(trigger); + await waitFor(() => expect(popover).to.have.class("is-visible")); + + await user.tab(); + + expect(outsideButton).to.have.focus; + await waitFor(() => expect(popover).not.to.have.class("is-visible")); + expect(trigger).to.have.attribute("aria-expanded", "false"); + }); + + it("should hide when focus leaves with no related target", async () => { + await fixture(createPopover()); + + const trigger = screen.getByTestId("popover-trigger"); + const popover = screen.getByTestId("popover"); + + await user.click(trigger); + await waitFor(() => expect(popover).to.have.class("is-visible")); + + trigger.dispatchEvent( + new FocusEvent("focusout", { + bubbles: true, + relatedTarget: null, + }) + ); + + expect(popover).not.to.have.class("is-visible"); + expect(trigger).to.have.attribute("aria-expanded", "false"); + }); +}); diff --git a/packages/stacks-classic/lib/components/popover/popover.ts b/packages/stacks-classic/lib/components/popover/popover.ts index 137d47069d..bfb14ad9cb 100644 --- a/packages/stacks-classic/lib/components/popover/popover.ts +++ b/packages/stacks-classic/lib/components/popover/popover.ts @@ -85,6 +85,17 @@ export abstract class BasePopoverController extends Stacks.StacksController { } } + /** + * Only menu popovers dismiss when focus leaves the reference/popover pair. + */ + protected get shouldHideOnFocusLeave() { + return ( + this.shouldHideOnOutsideClick && + (this.popoverElement?.getAttribute("role") === "menu" || + !!this.popoverElement?.querySelector('[role="menu"]')) + ); + } + /** * Initializes and validates controller variables */ @@ -316,6 +327,7 @@ export class PopoverController extends BasePopoverController { private boundHideOnOutsideClick!: (event: MouseEvent) => void; private boundHideOnEscapePress!: (event: KeyboardEvent) => void; + private boundHideOnFocusOut!: (event: FocusEvent) => void; /** * Toggles optional classes and accessibility attributes in addition to BasePopoverController.shown @@ -352,9 +364,19 @@ export class PopoverController extends BasePopoverController { this.boundHideOnOutsideClick || this.hideOnOutsideClick.bind(this); this.boundHideOnEscapePress = this.boundHideOnEscapePress || this.hideOnEscapePress.bind(this); + this.boundHideOnFocusOut = + this.boundHideOnFocusOut || this.hideOnFocusOut.bind(this); document.addEventListener("mousedown", this.boundHideOnOutsideClick); document.addEventListener("keyup", this.boundHideOnEscapePress); + this.referenceElement.addEventListener( + "focusout", + this.boundHideOnFocusOut + ); + this.popoverElement.addEventListener( + "focusout", + this.boundHideOnFocusOut + ); } /** @@ -363,6 +385,14 @@ export class PopoverController extends BasePopoverController { protected unbindDocumentEvents() { document.removeEventListener("mousedown", this.boundHideOnOutsideClick); document.removeEventListener("keyup", this.boundHideOnEscapePress); + this.referenceElement.removeEventListener( + "focusout", + this.boundHideOnFocusOut + ); + this.popoverElement.removeEventListener( + "focusout", + this.boundHideOnFocusOut + ); } /** @@ -402,6 +432,28 @@ export class PopoverController extends BasePopoverController { this.hide(e); } + /** + * Forces the popover to hide if keyboard focus leaves both the reference element and the popover. + * @param {FocusEvent} e - The focusout event from the reference or popover element + */ + private hideOnFocusOut(e: FocusEvent) { + if (!this.shouldHideOnFocusLeave) { + return; + } + + const relatedTarget = e.relatedTarget; + + if ( + relatedTarget instanceof Node && + (this.referenceElement.contains(relatedTarget) || + this.popoverElement.contains(relatedTarget)) + ) { + return; + } + + this.hide(e); + } + /** * Toggles all classes on the originating element based on the `class-toggle` data * @param {boolean=} show - A boolean indicating whether this is being triggered by a show or hide. diff --git a/packages/stacks-docs/src/docs/public/system/components/popovers.md b/packages/stacks-docs/src/docs/public/system/components/popovers.md index f95597fe61..fd9657e898 100644 --- a/packages/stacks-docs/src/docs/public/system/components/popovers.md +++ b/packages/stacks-docs/src/docs/public/system/components/popovers.md @@ -7,7 +7,7 @@ js: true --- @@ -82,8 +95,9 @@ onmouseenter={pstate.openTooltip} onmouseleave={pstate.closeTooltip} onfocusin={pstate.openTooltip} - onfocusout={pstate.closeTooltip} + onfocusout={onFocusOut} data-popper-placement={pstate.computedPlacement} + bind:this={contentElement} >
diff --git a/packages/stacks-svelte/src/components/Popover/PopoverReference.svelte b/packages/stacks-svelte/src/components/Popover/PopoverReference.svelte index e08a196f51..06c3780890 100644 --- a/packages/stacks-svelte/src/components/Popover/PopoverReference.svelte +++ b/packages/stacks-svelte/src/components/Popover/PopoverReference.svelte @@ -66,7 +66,11 @@ ref.setAttribute("aria-controls", `${pstate.id}-popover`); const toggle = pstate.dismissible ? pstate.toggle : pstate.open; ref.addEventListener("click", toggle); - return () => ref.removeEventListener("click", toggle); + ref.addEventListener("focusout", pstate.onFocusOut); + return () => { + ref.removeEventListener("click", toggle); + ref.removeEventListener("focusout", pstate.onFocusOut); + }; }; const setupTooltip = (ref: HTMLElement, pstate: PopoverState) => { @@ -83,13 +87,20 @@ }; }; + const setupControlledPopover = (ref: HTMLElement, pstate: PopoverState) => { + if (!pstate.tooltip) { + ref.addEventListener("focusout", pstate.onFocusOut); + } + return () => ref.removeEventListener("focusout", pstate.onFocusOut); + }; + onMount(() => { reference = setupRef(elementId, referenceWrapper, pstate); // if the popover is controlled, we delegate all the behavior to the consumer - if (pstate.controlled) return; + if (pstate.controlled) return setupControlledPopover(reference, pstate); - pstate.tooltip + return pstate.tooltip ? setupTooltip(reference, pstate) : setupPopover(reference, pstate); });