+ `,
+ 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 b41a417efa..d9e7faa006 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
*/
@@ -322,6 +333,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
@@ -358,9 +370,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
+ );
}
/**
@@ -369,6 +391,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
+ );
}
/**
@@ -408,6 +438,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/product/components/popovers.html b/packages/stacks-docs/product/components/popovers.html
index a0f11004f1..9a63043644 100644
--- a/packages/stacks-docs/product/components/popovers.html
+++ b/packages/stacks-docs/product/components/popovers.html
@@ -301,6 +301,80 @@
+ {% header "h4", "Menu popovers" %}
+
Menu popovers are dismissed when keyboard focus leaves the reference and popover. Apply role="menu" to the contained menu, or to the .s-popover root, to enable this behavior. Generic popovers that keep the default role="dialog" stay open when focus moves outside.
+
+
+{% highlight html %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endhighlight %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{% header "h4", "Dismissible" %}
In the case of new feature callouts, it may be appropriate to include an explicit dismiss button. You can add one using the styling provided by .s-popover--close.
In order for to close the popover with an explicit close button, you’ll need to add the controller to a parent as illustrated in the following example code: