spartan-ng/spartan

RFC: Conditional close-on-outside-pointer hook for brn-dialog/brn-popover (so nested CDK overlays don't dismiss the host)

Open

#1,382 opened on Jun 2, 2026

View on GitHub
 (2 comments) (1 reaction) (0 assignees)TypeScript (300 forks)auto 404
enhancementgood first issue

Repository metrics

Stars
 (2,788 stars)
PR merge metrics
 (PR metrics pending)

Description

Scope

popover, dialog

Information

Problem

BrnDialog (and therefore BrnPopover) only exposes closeOnOutsidePointerEvents as a boolean. There is no way to conditionally keep the dialog/popover open for a specific outside pointer event.

This breaks a very common composition: an overlay-based component opened from inside a popover — e.g. a brn-select (or any CDK-overlay component: autocomplete, combobox, menu, nested popover) placed inside brn-popover's content.

brn-select renders its listbox in its own CDK overlay, which is a sibling of the popover's overlay in the global overlay container — not a DOM descendant of the popover's pane. So a pointer interaction with the open select panel (or its backdrop) counts as an outside pointer event for the popover. With closeOnOutsidePointerEvents enabled (the default for brn-popover), the popover tears itself down together with the select the moment the user clicks the select.

Result: you cannot reliably put a select/combobox/menu inside a popover, which is a standard UI pattern (filter popovers, settings popovers, etc.).

Root cause

In BrnDialog the close is wired unconditionally whenever the boolean is true (current main, @spartan-ng/brain@0.0.1-alpha.702):

const closeOnOutsidePointerEvents =
  options?.closeOnOutsidePointerEvents ?? this._defaultOptions.closeOnOutsidePointerEvents;
if (closeOnOutsidePointerEvents) {
  cdkDialogRef.outsidePointerEvents
    .pipe(takeUntil(merge(destroyed$, optionsChanged$)))
    .subscribe(() => {
      /* close */
    });
}

There is no per-event gate between outsidePointerEvents firing and the close.

Proposed solution

Add an optional close predicate input to BrnDialog (inherited by BrnPopover) that gates the outside-pointer close:

/** Return `false` to keep the dialog/popover open for this outside pointer event. */
readonly closePredicate = input<(event: Event) => boolean>();

Wire it into the existing subscription:

if (closeOnOutsidePointerEvents) {
  cdkDialogRef.outsidePointerEvents
    .pipe(
      filter((event) => this.closePredicate()?.(event) ?? true),
      takeUntil(merge(destroyed$, optionsChanged$)),
    )
    .subscribe(() => {
      /* close */
    });
}

Usage:

<brn-popover [closePredicate]="keepOpenWhileNestedOverlayOpen">
  ...
</brn-popover>

Notes on the API shape:

  • This maps directly onto CDK's existing OverlayConfig.eventPredicate: (event: Event) => boolean ("determines if the overlay should receive a specific event or if the event should go to the next overlay in the stack"). Forwarding/surfacing that field would achieve the same result at the overlay layer.
  • A higher-level, batteries-included option would be an opt-in (e.g. ignoreNestedOverlayPointerEvents) where brain itself detects that the pointer event originated in a CDK overlay logically spawned from this dialog's content and skips the close. A generic closePredicate is the minimal, most flexible primitive; the nested-overlay behaviour can be built on top of it.

This is fully backward compatible — when closePredicate is not provided, behaviour is unchanged.

Reproduction

A brn-select inside a brn-popover:

  1. Open the popover.
  2. Open the select inside it.
  3. Click an option / click the select backdrop to dismiss the select.

Expected: the select closes; the popover stays open. Actual: the popover closes together with the select (the click is treated as an outside pointer event for the popover).

A minimal StackBlitz/GitHub reproduction can be provided on request.

Environment / verified against

  • @spartan-ng/brain: confirmed on 0.0.1-alpha.702 (latest at time of writing); closeOnOutsidePointerEvents is still boolean-only and BrnDialogOptions exposes no predicate.
  • Angular 21, @angular/cdk 21.

Alternatives / workarounds currently in use

We keep brn-popover open by reaching into CDK internals from a directive applied to the brn-popover element, and setting the overlay's eventPredicate on each open:

// Walk brn-dialog -> CDK DialogRef -> OverlayRef to reach OverlayConfig.eventPredicate.
// All access is optional-chained so a future refactor degrades to default close behaviour.
const overlayRef = (popover as any)._dialogRef()?._cdkDialogRef?.overlayRef;
const config = overlayRef?._config;
const popoverPane = overlayRef?.overlayElement;
if (!config || !popoverPane) return;

config.eventPredicate = (event: Event) => {
  if (event.type !== 'click' && event.type !== 'auxclick' && event.type !== 'contextmenu') return true;
  // Keep open only while one of THIS popover's own descendant overlays (e.g. a select panel) is open.
  return !hasOpenNestedOverlay(popoverPane);
};

This works but relies on private fields (_dialogRef, _cdkDialogRef, _config) that are version-specific and undocumented — exactly what a public closePredicate (or forwarded eventPredicate) input would replace.

I would be willing to submit a PR to fix this issue

  • Yes
  • No

Contributor guide