How to wrap ngMenu into a reusable component while letting consumers provide a custom trigger button?

Summary

A user asked how to wrap Angular’s ngMenuTrigger into a reusable component that allows the consumer to provide a custom trigger button. The challenge was that the ngMenuTrigger directive (likely part of the angular-aria library) requires a specific reference (#trigger) and attributes to be attached to the DOM element triggering the menu. Simply using ng-content to project a button created structural conflicts and potential accessibility issues (nested buttons). The solution involves using Content Projection with Template References or creating a Proxy Directive to bridge the consumer’s button with the internal menu logic.

Root Cause

The core issue lies in the decoupling between the host element (the button) and the Angular directives attached to it.

  1. Directive Host Binding: The ngMenuTrigger directive is designed to be attached directly to the trigger element (e.g., <button ngMenuTrigger>).
  2. Template References: The example relies on local template references (#origin, #trigger="ngMenuTrigger") which are required by the overlay configuration (cdkConnectedOverlay).
  3. Component Encapsulation: When wrapping this in a component, the inner <button> is distinct from the wrapper component. Angular’s projection (ng-content) inserts the consumer’s button as a child, but the wrapper component cannot easily “reach out” to attach directives to that projected child dynamically without a bridge mechanism.

Why This Happens in Real Systems

This is a common pattern in UI library development. Designers often want to standardize complex behaviors (like menus, tooltips, popovers) while maintaining flexibility for the “trigger” element (icons, text links, specific styling).

  • Directive Scoping: Directives apply to the specific DOM element they are attached to. Projected content retains its own scope.
  • Overlay Architecture: The Angular CDK ConnectedOverlay requires a specific Origin element. If the trigger is deep inside the projected content, the overlay positioner struggles to calculate the correct placement unless a specific reference is exposed.

Real-World Impact

  • Developer Experience (DX): If not solved, developers are forced to copy-paste the menu HTML structure (violating DRY principles) or use rigid components that don’t fit design systems.
  • Accessibility Risks: As noted in the query, nesting interactive elements (e.g., a projected <button> inside a wrapper that also listens for click events) can break screen reader navigation and keyboard focus management.
  • Maintenance Overhead: Tight coupling between the trigger and the menu logic makes updates to the menu behavior difficult to propagate across an application.

Example or Code

Here is a solution using Content Projection with Template References. This approach allows the consumer to define their own button but exposes a #trigger reference that the reusable component can bind to.

Reusable Component (ng-menu-wrapper.component.ts):

import { Component, Input, ContentChild, TemplateRef, ViewChild } from '@angular/core';
import { CdkConnectedOverlay } from '@angular/cdk/overlay';

@Component({
  selector: 'app-ng-menu-wrapper',
  template: `
    
    

    
      
      
    
  `,
})
export class NgMenuWrapperComponent {
  @Input() isOpen = false;

  // We expect the consumer to provide a template reference named "trigger"
  // bound to the CdkConnectedOverlay origin.
  @ContentChild('trigger', { read: CdkConnectedOverlay }) 
  triggerOrigin!: CdkConnectedOverlay;

  overlayPositions = [
    { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 4 },
  ];
}

Consumer Usage:


  
  
  

  
  
Settings

How Senior Engineers Fix It

Senior engineers resolve this by implementing a Bridge Pattern or Adapter Pattern. Instead of fighting the framework, they align the projection mechanism with the directive’s requirements.

  1. Expose Template References: They use @ContentChild to grab the projected element. If the library permits, they might create a Proxy Directive (e.g., [appMenuTriggerProxy]) that sits on the consumer’s button and communicates with the wrapper component via a shared service or HostListener.
  2. Strict Separation of Concerns: The wrapper handles the overlay logic (positioning, open/close state), while the consumer handles the trigger visual (button styling, icons).
  3. Accessibility First: They ensure the wrapper does not intercept events intended for the projected button unless necessary. They verify that ARIA attributes (aria-expanded, aria-controls) are correctly bound to the projected trigger element via the directive.

Why Juniors Miss It

  • Over-reliance on ng-content: Juniors often assume ng-content is a “magic box” that merges logic. They fail to realize that the projected content is a separate DOM entity with its own lifecycle.
  • Confusion with Template References (#): Template references are scoped to the component they are defined in. Juniors struggle to pass a reference from a projected child back up to the parent wrapper component.
  • Directive Attachment: They try to put the directive on the wrapper component (e.g., <app-menu-wrapper ngMenuTrigger>), which doesn’t work because the wrapper isn’t the clickable element. They miss the nuance of attaching the directive to the projected element.