# Popover

Popover is exposed as a standalone directive on a trigger element. Pass an `ng-template` with the panel body, toggle it on click by default, and optionally control open state from the parent.

## Import
```ts
import { PopoverDirective } from 'ui';
```

## Filter panel on a board
```ts
import { Component, TemplateRef, signal, viewChild, ChangeDetectionStrategy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ButtonComponent, DividerComponent, PopoverDirective, SwitchComponent } from 'ui';

type FilterKey = 'open' | 'waiting' | 'escalated' | 'mine';

interface FilterOption {
  key: FilterKey;
  label: string;
  hint: string;
  grouped?: boolean;
}

@Component({
  selector: 'app-popover-filters-demo',
  standalone: true,
  imports: [FormsModule, ButtonComponent, DividerComponent, PopoverDirective, SwitchComponent],
  changeDetection: ChangeDetectionStrategy.Eager,
  template: `
    <div
      style="display:flex;flex-direction:column;gap:1rem;width:100%;max-width:42rem;padding:1rem;border:1px solid var(--color-neutral-stroke-rest);border-radius:1rem;background:var(--color-neutral-background-rest)"
    >
      <div
        style="display:flex;flex-wrap:wrap;gap:0.75rem;align-items:center;justify-content:space-between"
      >
        <div>
          <div style="font-size:0.9375rem;font-weight:600">Customer issues</div>
          <div style="font-size:0.8125rem;color:var(--color-neutral-foreground2-rest)">
            128 open tickets across billing, onboarding, and support queues.
          </div>
        </div>

        <ui-button
          type="button"
          variant="secondary"
          appearance="outline"
          icon="filter"
          [uiPopover]="filtersTpl"
          uiPopoverAriaLabel="Issue filters"
          uiPopoverPosition="bottom"
          uiPopoverSize="medium"
        >
          Filters
          @if (activeFilterCount() > 0) {
            <span
              style="margin-left:0.375rem;padding:0.125rem 0.4375rem;border-radius:999px;background:var(--color-brand-background-rest);color:var(--color-brand-foreground-rest);font-size:0.6875rem;font-weight:600;line-height:1"
            >
              {{ activeFilterCount() }}
            </span>
          }
        </ui-button>
      </div>

      <div style="display:flex;flex-wrap:wrap;gap:0.5rem">
        @for (chip of activeChips(); track chip) {
          <span
            style="padding:0.25rem 0.625rem;border-radius:999px;background:var(--color-neutral-background2-rest);font-size:0.75rem;color:var(--color-neutral-foreground2-rest)"
          >
            {{ chip }}
          </span>
        } @empty {
          <span style="font-size:0.8125rem;color:var(--color-neutral-foreground3-rest)">
            No filters applied yet.
          </span>
        }
      </div>
    </div>

    <ng-template #filtersTpl>
      <div class="popover-panel">
        <div class="popover-panel__header">
          <div class="popover-panel__title">Filter issues</div>
          <div class="popover-panel__description">
            Narrow the queue without leaving the board. Changes apply when you confirm.
          </div>
        </div>

        <div class="popover-panel__body">
          @for (option of filterOptions; track option.key) {
            @if (option.grouped) {
              <ui-divider />
            }
            <div style="display:flex;align-items:center;justify-content:space-between;gap:1rem">
              <ui-switch
                labelPosition="none"
                [ariaLabel]="'Filter by ' + option.label"
                [(ngModel)]="filters[option.key]"
                [ngModelOptions]="{ standalone: true }"
              />
              <div style="flex:1;min-width:0;text-align:right">
                <div style="font-size:0.875rem;font-weight:600">{{ option.label }}</div>
                <div style="font-size:0.75rem;color:var(--color-neutral-foreground2-rest)">
                  {{ option.hint }}
                </div>
              </div>
            </div>
          }
        </div>

        <div class="popover-panel__footer">
          <ui-button type="button" variant="secondary" appearance="subtle" (click)="clearFilters()">
            Clear
          </ui-button>
          <ui-button type="button" variant="primary" (click)="applyFilters()"
            >Apply filters</ui-button
          >
        </div>
      </div>
    </ng-template>
  `,
})
export class PopoverFiltersDemoComponent {
  protected filtersTpl = viewChild.required<TemplateRef<unknown>>('filtersTpl');

  protected filters: Record<FilterKey, boolean> = {
    open: true,
    waiting: false,
    escalated: true,
    mine: false,
  };

  protected readonly filterOptions: FilterOption[] = [
    { key: 'open', label: 'Open', hint: 'Issues still in progress' },
    { key: 'waiting', label: 'Waiting on customer', hint: 'Blocked on customer reply' },
    { key: 'escalated', label: 'Escalated', hint: 'Raised to on-call queue' },
    { key: 'mine', label: 'Assigned to me', hint: 'Only tickets owned by you', grouped: true },
  ];

  protected applied = signal({ ...this.filters });

  protected activeFilterCount = signal(2);
  protected activeChips = signal<string[]>(['Open', 'Escalated']);

  protected clearFilters(): void {
    this.filters = { open: false, waiting: false, escalated: false, mine: false };
  }

  protected applyFilters(): void {
    this.applied.set({ ...this.filters });
    const chips: string[] = [];
    if (this.filters.open) chips.push('Open');
    if (this.filters.waiting) chips.push('Waiting on customer');
    if (this.filters.escalated) chips.push('Escalated');
    if (this.filters.mine) chips.push('Assigned to me');
    this.activeChips.set(chips);
    this.activeFilterCount.set(chips.length);
  }
}
```

## Placement with fallback
```ts
import { Component, TemplateRef, model, viewChild, ChangeDetectionStrategy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
  ButtonComponent,
  PopoverDirective,
  RadioButtonGroupComponent,
  type PopoverPosition,
  type RadioButtonItem,
} from 'ui';

@Component({
  selector: 'app-popover-placement-demo',
  standalone: true,
  imports: [FormsModule, ButtonComponent, PopoverDirective, RadioButtonGroupComponent],
  changeDetection: ChangeDetectionStrategy.Eager,
  template: `
    <div
      style="display:flex;flex-direction:column;gap:1.25rem;width:100%;min-height:22rem;padding:1.5rem;border:1px dashed var(--color-neutral-stroke-rest);border-radius:1rem;background:var(--color-neutral-background2-rest)"
    >
      <div style="font-size:0.8125rem;color:var(--color-neutral-foreground2-rest);max-width:32rem">
        Use one trigger and switch placement from the control below. The panel keeps the arrow
        aligned with the side that actually fits in the viewport.
      </div>

      <div style="flex:1;display:flex;align-items:center;justify-content:center;min-height:12rem">
        <ui-button
          type="button"
          variant="primary"
          icon="panel_top_expand"
          [uiPopover]="placementTpl"
          [(uiPopoverOpen)]="open"
          [uiPopoverPosition]="selectedPosition"
          uiPopoverAriaLabel="Placement preview"
          uiPopoverSize="medium"
        >
          Open panel
        </ui-button>
      </div>

      <ui-radio-button-group
        label="Preferred placement"
        [items]="placementItems"
        [(ngModel)]="selectedPosition"
        [ngModelOptions]="{ standalone: true }"
        layout="separate"
        appearance="outline"
        variant="secondary"
      />
    </div>

    <ng-template #placementTpl>
      <div class="popover-panel" style="min-width:14rem">
        <div class="popover-panel__header">
          <div class="popover-panel__title">Placement preview</div>
          <div class="popover-panel__description">
            Anchored to the trigger on the {{ selectedPosition }} side when space allows.
          </div>
        </div>
        <div class="popover-panel__body">
          <div
            style="font-size:0.8125rem;line-height:1.5;color:var(--color-neutral-foreground2-rest)"
          >
            Popovers are meant for compact panels such as filters, pickers, and quick actions—not
            one-line hints.
          </div>
        </div>
      </div>
    </ng-template>
  `,
})
export class PopoverPlacementDemoComponent {
  protected placementTpl = viewChild.required<TemplateRef<unknown>>('placementTpl');
  protected open = model(false);
  protected selectedPosition: PopoverPosition = 'bottom';

  protected readonly placementItems: RadioButtonItem[] = [
    { id: 'top', label: 'Top', value: 'top' },
    { id: 'bottom', label: 'Bottom', value: 'bottom' },
    { id: 'left', label: 'Left', value: 'left' },
    { id: 'right', label: 'Right', value: 'right' },
  ];
}
```

## Assignee picker
```ts
import {
  Component,
  TemplateRef,
  computed,
  signal,
  viewChild,
  ChangeDetectionStrategy,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
  AvatarComponent,
  ButtonComponent,
  DividerComponent,
  PopoverDirective,
  SearchComponent,
} from 'ui';

interface AssigneeOption {
  id: string;
  name: string;
  role: string;
  image?: string;
  initials?: string;
}

@Component({
  selector: 'app-popover-assignee-demo',
  standalone: true,
  imports: [
    FormsModule,
    AvatarComponent,
    ButtonComponent,
    DividerComponent,
    PopoverDirective,
    SearchComponent,
  ],
  changeDetection: ChangeDetectionStrategy.Eager,
  template: `
    <div
      style="display:flex;flex-direction:column;gap:0.875rem;width:100%;max-width:34rem;padding:1rem;border:1px solid var(--color-neutral-stroke-rest);border-radius:1rem;background:var(--color-neutral-background-rest)"
    >
      <div
        style="display:flex;flex-wrap:wrap;gap:0.75rem;align-items:center;justify-content:space-between"
      >
        <div style="min-width:0">
          <div style="font-size:0.9375rem;font-weight:600">API rate-limit alert</div>
          <div style="font-size:0.8125rem;color:var(--color-neutral-foreground2-rest)">
            Owner:
            <strong>{{ selectedAssignee()?.name || 'Unassigned' }}</strong>
          </div>
        </div>

        <ui-button
          type="button"
          variant="secondary"
          appearance="outline"
          icon="person"
          [uiPopover]="assigneeTpl"
          uiPopoverPosition="bottom"
          uiPopoverSize="large"
          uiPopoverAriaLabel="Assign issue owner"
        >
          Assign
        </ui-button>
      </div>

      @if (selectedAssignee(); as assignee) {
        <div
          style="display:flex;align-items:center;gap:0.75rem;padding:0.75rem;border:1px solid var(--color-neutral-stroke-rest);border-radius:0.75rem;background:var(--color-neutral-background2-rest)"
        >
          <ui-avatar
            [image]="assignee.image"
            [initials]="assignee.initials"
            [name]="assignee.name"
            size="medium"
          />
          <div style="min-width:0">
            <div style="font-size:0.875rem;font-weight:600">{{ assignee.name }}</div>
            <div style="font-size:0.75rem;color:var(--color-neutral-foreground2-rest)">
              {{ assignee.role }}
            </div>
          </div>
        </div>
      }
    </div>

    <ng-template #assigneeTpl>
      <div class="popover-panel" style="min-width:18rem">
        <div class="popover-panel__header">
          <div class="popover-panel__title">Assign owner</div>
          <div class="popover-panel__description">
            Pick someone from the on-call rotation or search by name.
          </div>
        </div>

        <div class="popover-panel__body">
          <ui-search
            placeholder="Search teammates"
            size="medium"
            [(ngModel)]="query"
            [ngModelOptions]="{ standalone: true }"
          />

          <div style="display:flex;flex-direction:column;gap:0.375rem">
            @for (person of filteredAssignees(); track person.id) {
              <button
                type="button"
                (click)="selectAssignee(person)"
                style="display:flex;align-items:center;gap:0.75rem;width:100%;padding:0.5rem 0.625rem;border:1px solid transparent;border-radius:0.625rem;background:transparent;text-align:left;cursor:pointer"
                [style.background]="
                  selectedAssignee()?.id === person.id
                    ? 'var(--color-neutral-background2-rest)'
                    : 'transparent'
                "
              >
                <ui-avatar
                  [image]="person.image"
                  [initials]="person.initials"
                  [name]="person.name"
                  size="small"
                />
                <span style="min-width:0">
                  <span style="display:block;font-size:0.875rem;font-weight:600">{{
                    person.name
                  }}</span>
                  <span
                    style="display:block;font-size:0.75rem;color:var(--color-neutral-foreground2-rest)"
                  >
                    {{ person.role }}
                  </span>
                </span>
              </button>
            }
          </div>

          <ui-divider />

          <ui-button type="button" variant="secondary" appearance="subtle" icon="person_add">
            Invite teammate
          </ui-button>
        </div>
      </div>
    </ng-template>
  `,
})
export class PopoverAssigneeDemoComponent {
  protected assigneeTpl = viewChild.required<TemplateRef<unknown>>('assigneeTpl');
  protected query = '';
  protected selectedAssignee = signal<AssigneeOption | null>(null);

  protected readonly assignees: AssigneeOption[] = [
    {
      id: 'river',
      name: 'River Chen',
      role: 'Platform on-call',
      image: 'https://i.pravatar.cc/150?img=12',
    },
    { id: 'morgan', name: 'Morgan Kelly', role: 'Support lead', initials: 'MK' },
    { id: 'wei', name: 'Wei Zhang', role: 'Billing ops', initials: 'WZ' },
  ];

  protected filteredAssignees = computed(() => {
    const query = this.query.trim().toLowerCase();
    if (!query) {
      return this.assignees;
    }

    return this.assignees.filter(
      person =>
        person.name.toLowerCase().includes(query) || person.role.toLowerCase().includes(query),
    );
  });

  protected selectAssignee(person: AssigneeOption): void {
    this.selectedAssignee.set(person);
  }
}
```

## Controlled column picker
```ts
import {
  Component,
  TemplateRef,
  computed,
  model,
  viewChild,
  ChangeDetectionStrategy,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ButtonComponent, PopoverDirective, SwitchComponent } from 'ui';

type ColumnKey = 'region' | 'accounts' | 'mrr' | 'owner';

interface ColumnOption {
  key: ColumnKey;
  label: string;
  hint: string;
}

@Component({
  selector: 'app-popover-column-picker-demo',
  standalone: true,
  imports: [FormsModule, ButtonComponent, PopoverDirective, SwitchComponent],
  changeDetection: ChangeDetectionStrategy.Eager,
  template: `
    <div
      style="display:flex;flex-direction:column;gap:0.75rem;width:100%;max-width:40rem;padding:1rem;border:1px solid var(--color-neutral-stroke-rest);border-radius:1rem;background:var(--color-neutral-background-rest)"
    >
      <div
        style="display:flex;flex-wrap:wrap;gap:0.75rem;align-items:center;justify-content:space-between"
      >
        <div style="font-size:0.9375rem;font-weight:600">Revenue by region</div>
        <ui-button
          type="button"
          variant="secondary"
          appearance="outline"
          icon="table"
          [uiPopover]="columnsTpl"
          [(uiPopoverOpen)]="open"
          uiPopoverAriaLabel="Choose visible columns"
          uiPopoverPosition="bottom"
        >
          Columns ({{ visibleColumnCount() }})
        </ui-button>
      </div>

      <div
        style="overflow:auto;border:1px solid var(--color-neutral-stroke-rest);border-radius:0.75rem"
      >
        <table style="width:100%;border-collapse:collapse;font-size:0.8125rem">
          <thead>
            <tr style="background:var(--color-neutral-background2-rest);text-align:left">
              @if (columns.region) {
                <th style="padding:0.625rem 0.75rem">Region</th>
              }
              @if (columns.accounts) {
                <th style="padding:0.625rem 0.75rem">Accounts</th>
              }
              @if (columns.mrr) {
                <th style="padding:0.625rem 0.75rem">MRR</th>
              }
              @if (columns.owner) {
                <th style="padding:0.625rem 0.75rem">Owner</th>
              }
            </tr>
          </thead>
          <tbody>
            <tr>
              @if (columns.region) {
                <td
                  style="padding:0.625rem 0.75rem;border-top:1px solid var(--color-neutral-stroke-rest)"
                >
                  DACH
                </td>
              }
              @if (columns.accounts) {
                <td
                  style="padding:0.625rem 0.75rem;border-top:1px solid var(--color-neutral-stroke-rest)"
                >
                  42
                </td>
              }
              @if (columns.mrr) {
                <td
                  style="padding:0.625rem 0.75rem;border-top:1px solid var(--color-neutral-stroke-rest)"
                >
                  €128k
                </td>
              }
              @if (columns.owner) {
                <td
                  style="padding:0.625rem 0.75rem;border-top:1px solid var(--color-neutral-stroke-rest)"
                >
                  Morgan Kelly
                </td>
              }
            </tr>
          </tbody>
        </table>
      </div>
    </div>

    <ng-template #columnsTpl>
      <div class="popover-panel" style="min-width:17rem">
        <div class="popover-panel__header">
          <div class="popover-panel__title">Visible columns</div>
          <div class="popover-panel__description">
            Toggle columns on or off. At least one metric should stay visible.
          </div>
        </div>

        <div class="popover-panel__body">
          @for (option of columnOptions; track option.key) {
            <div style="display:flex;align-items:center;justify-content:space-between;gap:1rem">
              <ui-switch
                labelPosition="none"
                [ariaLabel]="'Show ' + option.label + ' column'"
                [(ngModel)]="draft[option.key]"
                [ngModelOptions]="{ standalone: true }"
              />
              <div style="flex:1;min-width:0;text-align:right">
                <div style="font-size:0.875rem;font-weight:600">{{ option.label }}</div>
                <div style="font-size:0.75rem;color:var(--color-neutral-foreground2-rest)">
                  {{ option.hint }}
                </div>
              </div>
            </div>
          }
        </div>

        <div class="popover-panel__footer">
          <ui-button type="button" variant="secondary" appearance="subtle" (click)="resetDraft()">
            Reset
          </ui-button>
          <ui-button type="button" variant="primary" (click)="applyColumns()">Apply</ui-button>
        </div>
      </div>
    </ng-template>
  `,
})
export class PopoverColumnPickerDemoComponent {
  protected columnsTpl = viewChild.required<TemplateRef<unknown>>('columnsTpl');
  protected open = model(false);

  protected columns: Record<ColumnKey, boolean> = {
    region: true,
    accounts: true,
    mrr: true,
    owner: false,
  };

  protected draft: Record<ColumnKey, boolean> = { ...this.columns };

  protected readonly columnOptions: ColumnOption[] = [
    { key: 'region', label: 'Region', hint: 'Geography column' },
    { key: 'accounts', label: 'Accounts', hint: 'Active customer count' },
    { key: 'mrr', label: 'MRR', hint: 'Monthly recurring revenue' },
    { key: 'owner', label: 'Owner', hint: 'Account owner name' },
  ];

  protected visibleColumnCount = computed(() => Object.values(this.columns).filter(Boolean).length);

  protected resetDraft(): void {
    this.draft = { ...this.columns };
  }

  protected applyColumns(): void {
    this.columns = { ...this.draft };
    this.open.set(false);
  }
}
```

## Share and access
```ts
import { Component, TemplateRef, signal, viewChild, ChangeDetectionStrategy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
  AvatarComponent,
  ButtonComponent,
  DividerComponent,
  PopoverDirective,
  SwitchComponent,
  TagComponent,
  TextComponent,
} from 'ui';

@Component({
  selector: 'app-popover-share-demo',
  standalone: true,
  imports: [
    FormsModule,
    AvatarComponent,
    ButtonComponent,
    DividerComponent,
    PopoverDirective,
    SwitchComponent,
    TagComponent,
    TextComponent,
  ],
  changeDetection: ChangeDetectionStrategy.Eager,
  template: `
    <div
      style="display:flex;flex-direction:column;gap:0.875rem;width:100%;max-width:30rem;padding:1rem;border:1px solid var(--color-neutral-stroke-rest);border-radius:1rem;background:var(--color-neutral-background-rest)"
    >
      <div
        style="display:flex;flex-wrap:wrap;gap:0.75rem;align-items:flex-start;justify-content:space-between"
      >
        <div style="min-width:0">
          <div style="font-size:0.9375rem;font-weight:600">Q2 launch brief</div>
          <div style="font-size:0.8125rem;color:var(--color-neutral-foreground2-rest)">
            Shared with product, marketing, and customer success.
          </div>
        </div>

        <ui-button
          type="button"
          variant="primary"
          appearance="outline"
          icon="share"
          [uiPopover]="shareTpl"
          uiPopoverPosition="bottom"
          uiPopoverSize="large"
          uiPopoverAriaLabel="Share document"
        >
          Share
        </ui-button>
      </div>

      <div style="font-size:0.8125rem;color:var(--color-neutral-foreground3-rest)">
        Last action: {{ lastAction() }}
      </div>
    </div>

    <ng-template #shareTpl>
      <div class="popover-panel" style="min-width:21rem">
        <div class="popover-panel__header">
          <div class="popover-panel__title">Share brief</div>
          <div class="popover-panel__description">
            Invite teammates or copy a view-only link for stakeholders outside the workspace.
          </div>
        </div>

        <div class="popover-panel__body">
          <ui-text
            label="Invite by email"
            placeholder="name@company.com"
            [(ngModel)]="inviteEmail"
            [ngModelOptions]="{ standalone: true }"
          />

          <div style="display:flex;flex-wrap:wrap;gap:0.5rem">
            <ui-button type="button" variant="primary" icon="person_add" (click)="sendInvite()">
              Send invite
            </ui-button>
            <ui-button
              type="button"
              variant="secondary"
              appearance="outline"
              icon="copy"
              (click)="copyLink()"
            >
              Copy link
            </ui-button>
          </div>

          <ui-switch
            label="Anyone with the link can view"
            labelPosition="before"
            [(ngModel)]="linkAccessEnabled"
            [ngModelOptions]="{ standalone: true }"
          />

          @if (linkAccessEnabled) {
            <ui-text
              label="Share link"
              [ngModel]="shareLink"
              [ngModelOptions]="{ standalone: true }"
              [readonly]="true"
            />
          }

          <ui-divider />

          <div style="display:flex;flex-direction:column;gap:0.75rem">
            <div style="font-size:0.8125rem;font-weight:600">People with access</div>

            @for (person of collaborators; track person.email) {
              <div style="display:flex;align-items:center;gap:0.75rem">
                <ui-avatar [initials]="person.initials" [name]="person.name" size="small" />
                <div style="flex:1;min-width:0">
                  <div style="font-size:0.875rem;font-weight:600">{{ person.name }}</div>
                  <div
                    style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:0.75rem;color:var(--color-neutral-foreground2-rest)"
                  >
                    {{ person.email }}
                  </div>
                </div>
                <ui-tag [text]="person.role" appearance="subtle" variant="secondary" size="small" />
              </div>
            }
          </div>
        </div>
      </div>
    </ng-template>
  `,
})
export class PopoverShareDemoComponent {
  protected shareTpl = viewChild.required<TemplateRef<unknown>>('shareTpl');
  protected shareLink = 'https://ui.laczynski.dev/docs/popover?share=q2-launch-brief';
  protected inviteEmail = '';
  protected linkAccessEnabled = true;
  protected lastAction = signal('Not copied yet');

  protected readonly collaborators = [
    { name: 'River Chen', email: 'river.chen@northwind.dev', initials: 'RC', role: 'Can edit' },
    { name: 'Morgan Kelly', email: 'morgan.k@northwind.dev', initials: 'MK', role: 'Can view' },
  ];

  protected copyLink(): void {
    this.lastAction.set('Link copied to clipboard');
  }

  protected sendInvite(): void {
    const email = this.inviteEmail.trim();
    this.lastAction.set(email ? `Invite sent to ${email}` : 'Enter an email address first');
  }
}
```

## Accessibility

### Trigger semantics
The trigger receives `aria-haspopup="dialog"`, `aria-expanded`, and `aria-controls` while open. Provide `uiPopoverAriaLabel` when the panel heading is not obvious from the trigger alone.

### Keyboard and dismissal
Popover panels close on `Escape` and outside pointer interaction. Keep essential instructions visible outside the popover when users must understand the trigger before opening it.

| Key / input | Action |
| --- | --- |
| Click trigger | Toggles the popover when `uiPopoverTrigger` is `click` |
| Escape | Closes the open popover |
| Outside click | Closes the open popover |

### Popover versus tooltip
Use tooltip for short, non-interactive hints. Use popover when the surface needs buttons, inputs, or richer supporting content that users may interact with.
