---
title: "Panels & dialogs — Angular"
description: "Keep track of which sidebars, dialogs and menus are open, open them from commands, and restore them next time."
framework: "Angular"
source: "https://www.embedpdf.com/docs/headless/angular/ui/panels"
---

# Panels & dialogs

A viewer has sidebars (thumbnails, comments, search), dialogs and menus. The shell plugin keeps track of
which are open, so a toolbar button, a keyboard shortcut and your own code can all open the same
panel, and you can remember the layout for next time. You draw the panels; the plugin only holds their
state. Register `withShell()`:

**`shell-plugin.ts`**

```typescript
import { Component } from '@angular/core';
import { provideEmbedPdf } from '@embedpdf/angular/runtime';
import { withShell } from '@embedpdf/angular/shell';
import { engine } from './pdf';

@Component({
  selector: 'app-document-viewer',
  providers: [provideEmbedPdf({ engine }, /* … */ withShell())],
  template: `<!-- your toolbar, panels and pages -->`,
})
export class DocumentViewer {}
```

Then ask for a panel by name:

**`comments-panel.ts`**

```typescript
import { Component, inject } from '@angular/core';
import { EpdfPanelToggle, EpdfShell } from '@embedpdf/angular/shell';
import { CommentList } from './comment-list';

@Component({
  selector: 'app-comments',
  imports: [EpdfPanelToggle, CommentList],
  template: `
    <button epdfPanelToggle="comments" epdfPanelToggleExclusive="right">Comments</button>

    @if (panel.isOpen()) {
      <aside>
        <button (click)="panel.close()">Close</button>
        <app-comment-list />
      </aside>
    }
  `,
})
export class Comments {
  protected readonly panel = inject(EpdfShell).surface('comments');
}
```

The `epdfPanelToggle` directive toggles the panel from your own button, with `epdfPanelToggleExclusive`
for its [group](#one-panel-per-side), and sets the button's `aria-expanded`.

The names are yours: `'comments'`, `'thumbnails'`, `'settings'`. Each document keeps its own, so every
tab remembers which panels were open.

## One panel per side

Most layouts show one panel per side. Give panels a group with `exclusive`, and opening one closes the
others in that group:

```ts
shell.open('thumbnails', { exclusive: 'left' });
shell.open('outline', { exclusive: 'left' }); // closes thumbnails
shell.open('comments', { exclusive: 'right' }); // the left panel stays
```

**`sidebars.ts`**

```typescript
import {
  ChangeDetectionStrategy,
  Component,
  effect,
  inject,
  signal,
  untracked,
  ViewEncapsulation,
} from '@angular/core';
import { EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { OpenInput } from '@embedpdf/angular/runtime';
import { EpdfPageTemplate, EpdfStage, withStage } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { EpdfSearch, EpdfSearchLayer, withSearch } from '@embedpdf/angular/search';
import { EpdfShell, withShell } from '@embedpdf/angular/shell';
import { localEngine } from '@embedpdf/engine';

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

@Component({
  selector: 'demo-workspace',
  imports: [EpdfStage, EpdfPageTemplate, EpdfRenderLayer, EpdfSearchLayer],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <!-- Both panels share the right side: opening one closes the other. -->
    <div class="toolbar">
      <button
        type="button"
        class="button"
        [attr.aria-pressed]="searchPanel.isOpen()"
        (click)="searchPanel.toggle({ exclusive: 'right' })"
      >
        Search
      </button>
      <button
        type="button"
        class="button"
        [attr.aria-pressed]="notesPanel.isOpen()"
        (click)="notesPanel.toggle({ exclusive: 'right' })"
      >
        Notes
      </button>
    </div>
    <div class="workspace">
      <epdf-stage class="stage">
        <ng-template epdfPage>
          <epdf-render-layer />
          <epdf-search-layer />
        </ng-template>
      </epdf-stage>
      @if (searchPanel.isOpen()) {
        <aside class="panel" aria-label="Search">
          <header class="panel-header">
            <h3 class="panel-title">Search</h3>
            <button type="button" class="close" aria-label="Close" (click)="searchPanel.close()">
              ×
            </button>
          </header>
          <input
            #field
            class="field"
            type="search"
            aria-label="Search"
            [value]="text()"
            (input)="text.set(field.value)"
          />
          <ol class="list">
            @for (hit of search.hits(); track hit.page.objectNumber + ':' + hit.start) {
              <li>
                <button type="button" class="item" (click)="search.goToHit(hit)">
                  <span class="item-page">Page {{ hit.pageIndex + 1 }}</span>
                  @if (hit.snippet; as snippet) {
                    <span class="item-text">…{{ snippet.before }}<mark>{{ snippet.match }}</mark>{{ snippet.after }}…</span>
                  }
                </button>
              </li>
            }
          </ol>
        </aside>
      }
      @if (notesPanel.isOpen()) {
        <aside class="panel" aria-label="Notes">
          <header class="panel-header">
            <h3 class="panel-title">Notes</h3>
            <button type="button" class="close" aria-label="Close" (click)="notesPanel.close()">
              ×
            </button>
          </header>
          <textarea
            #area
            class="field notes"
            aria-label="Notes"
            placeholder="Your notes on this document…"
            [value]="notes()"
            (input)="notes.set(area.value)"
          ></textarea>
        </aside>
      }
    </div>
  `,
})
export class Workspace {
  private readonly shell = inject(EpdfShell);
  protected readonly search = inject(EpdfSearch);
  protected readonly searchPanel = this.shell.surface('search');
  protected readonly notesPanel = this.shell.surface('notes');
  protected readonly text = signal('PDF');
  // Your app's own data: it stays when the panel closes.
  protected readonly notes = signal('');

  constructor() {
    effect(() => {
      const text = this.text();
      untracked(() => void this.search.search({ text }));
    });
    // The search panel is open when the document is.
    this.shell.open('search', { exclusive: 'right' });
  }
}

@Component({
  selector: 'demo-root',
  imports: [EpdfDocumentGate, Workspace],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      withSearch(),
      withShell(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './sidebars.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <demo-workspace *epdfDocumentGate="let document; fallback: loading" />

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {}
```

**`sidebars.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --line-strong: #dce8fc;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --accent: #054fb3;
  --accent-tint: #e7f0fe;
  --mark: #ffe36e;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The panel buttons, above the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-bottom: 14px;
}

.button {
  font: inherit;
  font-size: 13px;
  font-weight: 550;
  line-height: 1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 9px 14px;
  cursor: pointer;
  box-shadow: 0 1px 2px var(--shadow);
  transition:
    background-color 0.15s ease,
    border-color 0.15s ease,
    color 0.15s ease;
}

.button:hover {
  background: var(--tint);
  border-color: var(--line-strong);
}

.button[aria-pressed='true'] {
  color: var(--accent);
  background: var(--accent-tint);
  border-color: var(--line-strong);
}

.button:focus-visible,
.close:focus-visible,
.item:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The pages and the open panel: stacked, and side by side where there's room */
.workspace {
  container-type: inline-size;
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}

.stage,
.panel {
  flex: 1 1 100%;
  box-sizing: border-box;
  border: 1px solid var(--line);
  border-radius: 14px;
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.stage {
  height: 340px;
  background: var(--canvas);
}

.panel {
  display: flex;
  flex-direction: column;
  gap: 10px;
  max-height: 280px;
  padding: 12px;
  background: var(--surface);
}

@container (min-width: 620px) {
  .stage {
    flex: 1 1 0;
    min-width: 0;
    height: 420px;
  }

  .panel {
    flex: 0 0 250px;
    max-height: 420px;
  }
}

/* Inside a panel */
.panel-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.panel-title {
  margin: 0;
  font-size: 14px;
  font-weight: 650;
  color: var(--ink);
}

.close {
  width: 28px;
  height: 28px;
  font: inherit;
  font-size: 18px;
  line-height: 1;
  color: var(--muted);
  background: transparent;
  border: 0;
  border-radius: 8px;
  cursor: pointer;
}

.close:hover {
  color: var(--ink);
  background: var(--tint);
}

.field {
  box-sizing: border-box;
  width: 100%;
  font: inherit;
  font-size: 13px;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 10px;
}

.field:focus-visible {
  outline: none;
  border-color: var(--focus);
  box-shadow: 0 0 0 3px rgb(125 182 255 / 0.35);
}

.notes {
  flex: 1;
  min-height: 140px;
  resize: none;
}

.list {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  margin: 0;
  padding: 0;
  list-style: none;
}

.item {
  display: flex;
  flex-direction: column;
  gap: 3px;
  width: 100%;
  font: inherit;
  text-align: left;
  color: var(--ink);
  background: transparent;
  border: 0;
  border-radius: 9px;
  padding: 8px;
  cursor: pointer;
}

.item:hover {
  background: var(--tint);
}

.item-page {
  font-size: 11px;
  font-weight: 650;
  color: var(--muted);
}

.item-text {
  font-size: 13px;
  line-height: 1.4;
}

.item-text mark {
  background: var(--mark);
  color: inherit;
  border-radius: 2px;
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

## Open a panel with something in it

A panel can carry data, such as which comment to scroll to:

```ts
shell.open('comments', { exclusive: 'right', props: { focus: thread.root.ref } });
```

The panel reads them:

**`panel-props.ts`**

```typescript
import { Component, inject } from '@angular/core';
import { EpdfShell } from '@embedpdf/angular/shell';
import { CommentList } from './comment-list';

@Component({
  selector: 'app-comments-panel',
  imports: [CommentList],
  template: `<app-comment-list [focus]="panel.props().focus" />`,
})
export class CommentsPanel {
  protected readonly panel = inject(EpdfShell).surface('comments'); // props(): { focus: … }
}
```

`shell.updateSurfaceProps('comments', { focus: nextThread.root.ref })` changes them without reopening the
panel.

**`props.ts`**

```typescript
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  inject,
  ViewEncapsulation,
} from '@angular/core';
import { EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { OpenInput } from '@embedpdf/angular/runtime';
import { EpdfPageTemplate, EpdfStage, withStage } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { EpdfSearch, EpdfSearchLayer, withSearch } from '@embedpdf/angular/search';
import type { SearchHit } from '@embedpdf/angular/search';
import { EpdfShell, withShell } from '@embedpdf/angular/shell';
import { localEngine } from '@embedpdf/engine';

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

// The panel shows the match its props name, and steps to the next one without reopening.
@Component({
  selector: 'aside[demoMatchPanel]',
  changeDetection: ChangeDetectionStrategy.OnPush,
  host: { class: 'panel', 'aria-label': 'Match' },
  template: `
    <header class="panel-header">
      <h3 class="panel-title">Match {{ index() + 1 }} of {{ search.hits().length }}</h3>
      <button type="button" class="close" aria-label="Close" (click)="panel.close()">×</button>
    </header>
    @if (hit(); as hit) {
      <p class="page">Page {{ hit.pageIndex + 1 }}</p>
      @if (hit.snippet; as snippet) {
        <p class="snippet">…{{ snippet.before }}<mark>{{ snippet.match }}</mark>{{ snippet.after }}…</p>
      }
    } @else {
      <p class="page">Searching…</p>
    }
    <div class="steps">
      <button type="button" class="button" [disabled]="index() === 0" (click)="show(index() - 1)">
        ← Previous
      </button>
      <button
        type="button"
        class="button"
        [disabled]="index() >= search.hits().length - 1"
        (click)="show(index() + 1)"
      >
        Next →
      </button>
    </div>
  `,
})
export class MatchPanel {
  private readonly shell = inject(EpdfShell);
  protected readonly search = inject(EpdfSearch);
  protected readonly panel = this.shell.surface('match');
  protected readonly index = computed(() => {
    const index = this.panel.props()['index'];
    return typeof index === 'number' ? index : 0;
  });
  protected readonly hit = computed(() => this.search.hits()[this.index()]);

  protected show(next: number) {
    this.shell.updateSurfaceProps('match', { index: next });
    this.search.goToHit(this.search.hits()[next]);
  }
}

@Component({
  selector: 'div[demoWorkspace]',
  imports: [EpdfStage, EpdfPageTemplate, EpdfRenderLayer, EpdfSearchLayer, MatchPanel],
  changeDetection: ChangeDetectionStrategy.OnPush,
  host: { class: 'workspace' },
  template: `
    <epdf-stage class="stage">
      <ng-template epdfPage>
        <epdf-render-layer />
        <!-- A click on a match opens it in the panel. -->
        <epdf-search-layer (hitClick)="open($event)" />
      </ng-template>
    </epdf-stage>
    @if (panel.isOpen()) {
      <aside demoMatchPanel></aside>
    }
  `,
})
export class Workspace {
  private readonly shell = inject(EpdfShell);
  private readonly search = inject(EpdfSearch);
  protected readonly panel = this.shell.surface('match');

  constructor() {
    // Every "PDF" on the pages, and the first one open in the panel.
    void this.search.search({ text: 'PDF' });
    this.shell.open('match', { exclusive: 'right', props: { index: 0 } });
  }

  protected open(hit: SearchHit) {
    const index = this.search.hits().indexOf(hit);
    this.shell.open('match', { exclusive: 'right', props: { index } });
  }
}

@Component({
  selector: 'demo-root',
  imports: [EpdfDocumentGate, Workspace],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      withSearch(),
      withShell(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './props.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <div demoWorkspace *epdfDocumentGate="let document; fallback: loading"></div>

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {}
```

**`props.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --line-strong: #dce8fc;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --mark: #ffe36e;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The pages and the panel: stacked, and side by side where there's room */
.workspace {
  container-type: inline-size;
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}

.stage,
.panel {
  flex: 1 1 100%;
  box-sizing: border-box;
  border: 1px solid var(--line);
  border-radius: 14px;
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.stage {
  height: 340px;
  background: var(--canvas);
}

.panel {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 12px;
  background: var(--surface);
}

@container (min-width: 620px) {
  .stage {
    flex: 1 1 0;
    min-width: 0;
    height: 420px;
  }

  .panel {
    flex: 0 0 250px;
  }
}

/* Inside the panel */
.panel-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.panel-title {
  margin: 0;
  font-size: 14px;
  font-weight: 650;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.close {
  width: 28px;
  height: 28px;
  font: inherit;
  font-size: 18px;
  line-height: 1;
  color: var(--muted);
  background: transparent;
  border: 0;
  border-radius: 8px;
  cursor: pointer;
}

.close:hover {
  color: var(--ink);
  background: var(--tint);
}

.page {
  margin: 0;
  font-size: 12px;
  font-weight: 650;
  color: var(--muted);
}

.snippet {
  margin: 0;
  font-size: 14px;
  line-height: 1.5;
  color: var(--ink);
}

.snippet mark {
  background: var(--mark);
  color: inherit;
  border-radius: 2px;
}

.steps {
  display: flex;
  gap: 8px;
  margin-top: auto;
}

.button {
  flex: 1;
  font: inherit;
  font-size: 13px;
  font-weight: 550;
  line-height: 1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 9px 10px;
  cursor: pointer;
  box-shadow: 0 1px 2px var(--shadow);
  transition:
    background-color 0.15s ease,
    border-color 0.15s ease;
}

.button:hover {
  background: var(--tint);
  border-color: var(--line-strong);
}

.button:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

.button:focus-visible,
.close:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

## Put the cursor in a panel

A panel you type in, such as search, can take the cursor as it opens. Ask for it when you open the
panel:

```ts
shell.open('search', { focus: true });
```

and say which element takes it, with the `epdfSurfaceFocus` directive:

**`panel-focus.ts`**

```typescript
import { Component } from '@angular/core';
import { EpdfSurfaceFocus } from '@embedpdf/angular/shell';

@Component({
  selector: 'app-search-panel',
  imports: [EpdfSurfaceFocus],
  // The cursor goes in the field when the panel asks for it, its text selected.
  template: `<input epdfSurfaceFocus="search" epdfSurfaceFocusSelect placeholder="Search" />`,
})
export class SearchPanel {}
```

The element takes focus on every open that asks, also when the panel is open already (pressing a Find
key again), and when it appears while the request is up, as a panel whose code loads on its first
open does. A plain `open(id)` leaves the cursor where it is. `epdfSurfaceFocusSelect` selects the
field's text, so typing replaces it.

The request is the panel's `focusRequest` in [State](#state): how many opens asked since the panel
opened, back to 0 when it closes. Snapshots leave it out.

## From commands

A [command](commands) can open a panel with no code: give it `panel`, and its button toggles the panel
and shows as pressed while it's open:

```ts
{ id: 'panel:comments', label: 'Comments', icon: 'comment', panel: { id: 'comments', exclusive: 'right' } }
```

With `focus: true` in `panel`, opening it asks for the cursor, as above. `modal` does the same for a
dialog, and `menu` for a menu.

## Menus

Menus are tracked separately, since several can be open at once, such as a menu and its submenu:

```ts
shell.openMenu('more');
shell.closeAllMenus(); // for a click outside, or Escape
```

`openMenus` in [State](#state) lists the open menus, the last one opened on top.

**`menus.ts`**

```typescript
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  DOCUMENT,
  effect,
  ElementRef,
  inject,
  viewChild,
  ViewEncapsulation,
} from '@angular/core';
import { EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { OpenInput } from '@embedpdf/angular/runtime';
import { EpdfPageTemplate, EpdfStage, withStage } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { EpdfInteraction, withInteraction } from '@embedpdf/angular/interaction';
import { EpdfSelectionLayer, withSelection } from '@embedpdf/angular/selection';
import { EpdfShell, withShell } from '@embedpdf/angular/shell';
import { localEngine } from '@embedpdf/engine';

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

const TOOLS = [
  { id: 'pointer', label: 'Select text' },
  { id: 'pan', label: 'Scroll with the hand' },
];

// A menu with a submenu: opening the submenu leaves its menu open.
@Component({
  selector: 'div[demoViewMenu]',
  changeDetection: ChangeDetectionStrategy.OnPush,
  host: { class: 'toolbar' },
  template: `
    <div class="menubar" #bar>
      <button
        type="button"
        class="button"
        aria-haspopup="menu"
        [attr.aria-expanded]="isOpen('view')"
        (click)="shell.toggleMenu('view')"
      >
        View ▾
      </button>
      @if (isOpen('view')) {
        <div class="menu" role="menu">
          <button
            type="button"
            role="menuitem"
            class="menu-item"
            aria-haspopup="menu"
            [attr.aria-expanded]="isOpen('view-tool')"
            (click)="shell.toggleMenu('view-tool')"
          >
            Tool <span aria-hidden="true">▸</span>
          </button>
          <button type="button" role="menuitem" class="menu-item" (click)="shell.closeAllMenus()">
            Close menus
          </button>
          @if (isOpen('view-tool')) {
            <div class="menu submenu" role="menu">
              @for (tool of tools; track tool.id) {
                <button
                  type="button"
                  role="menuitemradio"
                  [attr.aria-checked]="tool.id === interaction.activeToolId()"
                  class="menu-item"
                  (click)="choose(tool.id)"
                >
                  {{ tool.label }}
                </button>
              }
            </div>
          }
        </div>
      }
    </div>
    <output class="readout">Open menus: {{ anyOpen() ? shell.openMenus().join(' › ') : 'none' }}</output>
  `,
})
export class ViewMenu {
  protected readonly shell = inject(EpdfShell);
  protected readonly interaction = inject(EpdfInteraction);
  protected readonly tools = TOOLS;
  protected readonly anyOpen = computed(() => this.shell.openMenus().length > 0);
  private readonly bar = viewChild.required<ElementRef<HTMLElement>>('bar');

  constructor() {
    const document = inject(DOCUMENT);
    // A press outside the menus, or Escape, closes all of them.
    effect((onCleanup) => {
      if (!this.anyOpen()) return;
      const outside = (event: PointerEvent) => {
        if (!this.bar().nativeElement.contains(event.target as Node)) this.shell.closeAllMenus();
      };
      const escape = (event: KeyboardEvent) => {
        if (event.key === 'Escape') this.shell.closeAllMenus();
      };
      document.addEventListener('pointerdown', outside, true);
      document.addEventListener('keydown', escape);
      onCleanup(() => {
        document.removeEventListener('pointerdown', outside, true);
        document.removeEventListener('keydown', escape);
      });
    });
  }

  protected isOpen(id: string) {
    return this.shell.openMenus().includes(id);
  }

  protected choose(toolId: string) {
    this.interaction.activateTool(toolId);
    this.shell.closeAllMenus();
  }
}

@Component({
  selector: 'demo-workspace',
  imports: [EpdfStage, EpdfPageTemplate, EpdfRenderLayer, EpdfSelectionLayer, ViewMenu],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div demoViewMenu></div>
    <epdf-stage class="stage">
      <ng-template epdfPage>
        <epdf-render-layer />
        <epdf-selection-layer />
      </ng-template>
    </epdf-stage>
  `,
})
export class Workspace {
  constructor() {
    // The menu and its submenu are open when the document is.
    const shell = inject(EpdfShell);
    shell.openMenu('view');
    shell.openMenu('view-tool');
  }
}

@Component({
  selector: 'demo-root',
  imports: [EpdfDocumentGate, Workspace],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      withInteraction(),
      withSelection(),
      withShell(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './menus.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <demo-workspace *epdfDocumentGate="let document; fallback: loading" />

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {}
```

**`menus.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --line-strong: #dce8fc;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --accent: #054fb3;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The menu button, above the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 14px;
  margin-bottom: 14px;
}

.menubar {
  position: relative;
  z-index: 1;
}

.button {
  font: inherit;
  font-size: 13px;
  font-weight: 550;
  line-height: 1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 9px 14px;
  cursor: pointer;
  box-shadow: 0 1px 2px var(--shadow);
}

.button:hover,
.button[aria-expanded='true'] {
  background: var(--tint);
  border-color: var(--line-strong);
}

.readout {
  font-size: 13px;
  color: var(--muted);
}

/* A menu below its button, and a submenu beside its item */
.menu {
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  display: flex;
  flex-direction: column;
  min-width: 170px;
  padding: 5px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 12px;
  box-shadow: 0 12px 32px rgb(7 32 76 / 0.18);
}

.submenu {
  top: 0;
  left: calc(100% + 4px);
}

.menu-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  font: inherit;
  font-size: 13px;
  text-align: left;
  white-space: nowrap;
  color: var(--ink);
  background: transparent;
  border: 0;
  border-radius: 8px;
  padding: 8px 10px;
  cursor: pointer;
}

.menu-item:hover,
.menu-item[aria-expanded='true'] {
  background: var(--tint);
}

.menu-item[aria-checked='true'] {
  color: var(--accent);
  font-weight: 600;
}

.menu-item[aria-checked='true']::after {
  content: '✓';
}

.button:focus-visible,
.menu-item:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The pages */
.stage {
  height: 420px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

## Remember the layout

```ts
localStorage.setItem('panels', JSON.stringify(shell.getSnapshot()));

// next time
shell.applySnapshot(JSON.parse(localStorage.getItem('panels')!));
```

**`remember.ts`**

```typescript
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  inject,
  signal,
  ViewEncapsulation,
} from '@angular/core';
import { EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { OpenInput } from '@embedpdf/angular/runtime';
import { EpdfPageTemplate, EpdfStage, withStage } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { EpdfShell, withShell } from '@embedpdf/angular/shell';
import type { ShellSnapshot } from '@embedpdf/angular/shell';
import { localEngine } from '@embedpdf/engine';

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

// The browser may refuse storage, in a private window: then nothing is remembered.
const loadLayout = (): ShellSnapshot | null => {
  try {
    return JSON.parse(localStorage.getItem('panels') ?? 'null') as ShellSnapshot | null;
  } catch {
    return null;
  }
};
const saveLayout = (snapshot: ShellSnapshot) => {
  try {
    localStorage.setItem('panels', JSON.stringify(snapshot));
  } catch {
    // Not remembered this time.
  }
};

const TIPS = [
  'Ctrl or ⌘ and scroll to zoom',
  'Pinch to zoom on a touch screen',
  'Drag between pages to scroll',
];

@Component({
  selector: 'demo-workspace',
  imports: [EpdfStage, EpdfPageTemplate, EpdfRenderLayer],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="toolbar">
      <button
        type="button"
        class="button"
        [attr.aria-pressed]="tips.isOpen()"
        (click)="tips.toggle({ exclusive: 'left' })"
      >
        Tips
      </button>
      <button
        type="button"
        class="button"
        [attr.aria-pressed]="notes.isOpen()"
        (click)="notes.toggle({ exclusive: 'right' })"
      >
        Notes
      </button>
      <span class="spacer"></span>
      <button type="button" class="button" (click)="save()">Save layout</button>
      <button type="button" class="button" [disabled]="!saved()" (click)="restore()">
        Restore
      </button>
    </div>
    <p class="readout">Open: {{ open() || 'no panels' }}</p>
    <div class="workspace">
      @if (tips.isOpen()) {
        <aside class="panel" aria-label="Tips">
          <h3 class="panel-title">Tips</h3>
          <ul class="tips">
            @for (tip of tipList; track tip) {
              <li>{{ tip }}</li>
            }
          </ul>
        </aside>
      }
      <epdf-stage class="stage">
        <ng-template epdfPage>
          <epdf-render-layer />
        </ng-template>
      </epdf-stage>
      @if (notes.isOpen()) {
        <aside class="panel" aria-label="Notes">
          <h3 class="panel-title">Notes</h3>
          <textarea
            class="notes"
            aria-label="Notes"
            placeholder="Your notes on this document…"
          ></textarea>
        </aside>
      }
    </div>
  `,
})
export class Workspace {
  private readonly shell = inject(EpdfShell);
  protected readonly tips = this.shell.surface('tips');
  protected readonly notes = this.shell.surface('notes');
  protected readonly tipList = TIPS;
  protected readonly saved = signal(loadLayout());
  protected readonly open = computed(() =>
    this.shell
      .openSurfaces()
      .map((surface) => surface.id)
      .join(', '),
  );

  constructor() {
    // The layout saved last time, or the tips the first time.
    const layout = loadLayout();
    if (layout) this.shell.applySnapshot(layout);
    else this.shell.open('tips', { exclusive: 'left' });
  }

  protected save() {
    const layout = this.shell.getSnapshot();
    saveLayout(layout);
    this.saved.set(layout);
  }

  protected restore() {
    const saved = this.saved();
    if (saved) this.shell.applySnapshot(saved);
  }
}

@Component({
  selector: 'demo-root',
  imports: [EpdfDocumentGate, Workspace],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      withShell(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './remember.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <demo-workspace *epdfDocumentGate="let document; fallback: loading" />

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {}
```

**`remember.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --line-strong: #dce8fc;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --accent: #054fb3;
  --accent-tint: #e7f0fe;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The panel buttons and the layout buttons, above the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
}

.spacer {
  flex: 1;
}

.button {
  font: inherit;
  font-size: 13px;
  font-weight: 550;
  line-height: 1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 9px 14px;
  cursor: pointer;
  box-shadow: 0 1px 2px var(--shadow);
  transition:
    background-color 0.15s ease,
    border-color 0.15s ease,
    color 0.15s ease;
}

.button:hover {
  background: var(--tint);
  border-color: var(--line-strong);
}

.button[aria-pressed='true'] {
  color: var(--accent);
  background: var(--accent-tint);
  border-color: var(--line-strong);
}

.button:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

.button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.readout {
  margin: 10px 0 12px;
  font-size: 13px;
  color: var(--muted);
}

/* A panel on each side of the pages where there's room, stacked where there isn't */
.workspace {
  container-type: inline-size;
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}

.stage,
.panel {
  flex: 1 1 100%;
  box-sizing: border-box;
  border: 1px solid var(--line);
  border-radius: 14px;
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.stage {
  height: 340px;
  background: var(--canvas);
}

.panel {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 12px;
  background: var(--surface);
}

@container (min-width: 620px) {
  .stage {
    flex: 1 1 0;
    min-width: 0;
    height: 420px;
  }

  .panel {
    flex: 0 0 190px;
  }
}

/* Inside a panel */
.panel-title {
  margin: 0;
  font-size: 14px;
  font-weight: 650;
  color: var(--ink);
}

.tips {
  margin: 0;
  padding-left: 18px;
  font-size: 13px;
  line-height: 1.5;
  color: var(--ink);
}

.tips li + li {
  margin-top: 6px;
}

.notes {
  flex: 1;
  min-height: 120px;
  box-sizing: border-box;
  width: 100%;
  font: inherit;
  font-size: 13px;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 10px;
  resize: none;
}

.notes:focus-visible {
  outline: none;
  border-color: var(--focus);
  box-shadow: 0 0 0 3px rgb(125 182 255 / 0.35);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

## Methods

Everything `inject(EpdfShell)` gives you.

| Method                                            | What it does                                                                                                                                                                                                                                            |
| :------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `open(id, { exclusive?, props?, focus? })`        | Opens a panel or dialog. `exclusive` closes the others in that group, `props` travels with it, and `focus` asks for the cursor, also when it's open. [One panel per side](#one-panel-per-side), [Put the cursor in a panel](#put-the-cursor-in-a-panel) |
| `toggle(id, { exclusive?, props?, focus? })`      | Opens a closed panel, with the options `open` takes, or closes an open one                                                                                                                                                                              |
| `close(id)`, `closeAll()`                         | Closes one panel, or every panel                                                                                                                                                                                                                        |
| `updateSurfaceProps(id, props)`                   | Changes a panel's `props` without reopening it. [Open a panel with something in it](#open-a-panel-with-something-in-it)                                                                                                                                 |
| `openMenu(id)`, `closeMenu(id)`, `toggleMenu(id)` | Opens, closes or toggles one menu. Opening a menu leaves the others open, so a submenu stays above its menu. [Menus](#menus)                                                                                                                            |
| `closeAllMenus()`                                 | Closes every menu, for a click outside or Escape                                                                                                                                                                                                        |
| `getSnapshot()`, `applySnapshot(snapshot)`        | Everything the plugin holds, as plain data you can store; and back. [Remember the layout](#remember-the-layout)                                                                                                                                         |

In a component, `shell.surface(id)` gives you one panel: `isOpen()` and `props()` as signals, and `open()`, `close()` and `toggle()`.

## State

Every value is a signal on `inject(EpdfShell)`: read it in a template or a `computed()`, and your view updates when it changes. The method in the last column reads the same value once.

| Field            | What it is                                                                                     | Read once                                                          |
| :--------------- | :--------------------------------------------------------------------------------------------- | :----------------------------------------------------------------- |
| `openSurfaces()` | The open panels and dialogs, each with its `id`, `exclusive` group, `props` and `focusRequest` | `listOpenSurfaces()`, or `isOpen(id)` and `getSurface(id)` for one |
| `openMenus()`    | The open menus' ids, the last one opened on top                                                | `listOpenMenus()`, or `isMenuOpen(id)` for one                     |

## Events

**`panel-events.ts`**

```typescript
import { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EpdfShell } from '@embedpdf/angular/shell';
import { analytics } from './analytics';

@Component({
  selector: 'app-viewer-layout',
  template: `<!-- your toolbar, panels and pages -->`,
})
export class ViewerLayout {
  private readonly shell = inject(EpdfShell);

  constructor() {
    this.shell.surfaceOpened$
      .pipe(takeUntilDestroyed())
      .subscribe(({ id }) => analytics.track('panel', { id }));
  }
}
```

| Event            | Fires when               | Carries       |
| :--------------- | :----------------------- | :------------ |
| `surfaceOpened$` | A panel or dialog opened | `id`, `props` |
| `surfaceClosed$` | A panel or dialog closed | `id`          |
| `menuOpened$`    | A menu opened            | `id`          |
| `menuClosed$`    | A menu closed            | `id`          |

## Good to know

### No document yet

The panel services work before a document is open: every panel reads as closed, and opening one does
nothing until there is a document. So a toolbar outside `*epdfDocumentGate` can use them.
