---
title: "Panels & dialogs — Vue"
description: "Keep track of which sidebars, dialogs and menus are open, open them from commands, and restore them next time."
framework: "Vue"
source: "https://www.embedpdf.com/docs/headless/vue/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 `shellPlugin()`:

**`shell-plugin.vue`**

```vue
<script setup lang="ts">
import { Viewer } from '@embedpdf/vue/runtime';
import { shellPlugin } from '@embedpdf/vue/shell';
import { engine } from './pdf';

const plugins = [/* … */ shellPlugin()];
</script>

<template>
  <Viewer :engine :plugins>
    <!-- your toolbar, panels and pages -->
  </Viewer>
</template>
```

Then ask for a panel by name:

**`comments-panel.vue`**

```vue
<script setup lang="ts">
import { useSurface } from '@embedpdf/vue/shell';
import CommentList from './CommentList.vue';

const { isOpen, close, toggle } = useSurface('comments');
</script>

<template>
  <button @click="toggle({ exclusive: 'right' })">Comments</button>

  <aside v-if="isOpen">
    <button @click="close()">Close</button>
    <CommentList />
  </aside>
</template>
```

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
```

**`App.vue`**

```vue
<script setup lang="ts">
import { DocumentGate, Viewer } from '@embedpdf/vue/runtime';
import type { OpenInput } from '@embedpdf/vue/runtime';
import { stagePlugin } from '@embedpdf/vue/stage';
import { renderPlugin } from '@embedpdf/vue/render';
import { searchPlugin } from '@embedpdf/vue/search';
import { shellPlugin } from '@embedpdf/vue/shell';
import { localEngine } from '@embedpdf/engine';
import Workspace from './Workspace.vue';

import '../sidebars.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), searchPlugin(), shellPlugin()];

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()) };
};
</script>

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: ebook }]">
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <Workspace />
    </DocumentGate>
  </Viewer>
</template>
```

**`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);
}
```

**`NotesPanel.vue`**

```vue
<script setup lang="ts">
import { useSurface } from '@embedpdf/vue/shell';

// `v-model`: the notes are the app's, so they stay while the panel is closed.
const notes = defineModel<string>({ required: true });
const { isOpen, close } = useSurface('notes');
</script>

<template>
  <aside v-if="isOpen" class="panel" aria-label="Notes">
    <header class="panel-header">
      <h3 class="panel-title">Notes</h3>
      <button type="button" class="close" aria-label="Close" @click="close()">×</button>
    </header>
    <textarea
      v-model="notes"
      class="field notes"
      aria-label="Notes"
      placeholder="Your notes on this document…"
    />
  </aside>
</template>
```

**`PanelButton.vue`**

```vue
<script setup lang="ts">
import { useSurface } from '@embedpdf/vue/shell';

// Both panels share the right side: opening one closes the other.
const props = defineProps<{ id: string; label: string }>();
const { isOpen, toggle } = useSurface(() => props.id);
</script>

<template>
  <button
    type="button"
    class="button"
    :aria-pressed="isOpen"
    @click="toggle({ exclusive: 'right' })"
  >
    {{ label }}
  </button>
</template>
```

**`SearchPanel.vue`**

```vue
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useSearch, useSearchHits } from '@embedpdf/vue/search';
import { useSurface } from '@embedpdf/vue/shell';

const { isOpen, close } = useSurface('search');
const search = useSearch();
const hits = useSearchHits();
const text = ref('PDF');

watch(text, (query) => void search.search({ text: query }), { immediate: true });
</script>

<template>
  <aside v-if="isOpen" class="panel" aria-label="Search">
    <header class="panel-header">
      <h3 class="panel-title">Search</h3>
      <button type="button" class="close" aria-label="Close" @click="close()">×</button>
    </header>
    <input v-model="text" class="field" type="search" aria-label="Search" />
    <ol class="list">
      <li v-for="hit in hits" :key="`${hit.page.objectNumber}:${hit.start}`">
        <button type="button" class="item" @click="search.goToHit(hit)">
          <span class="item-page">Page {{ hit.pageIndex + 1 }}</span>
          <span v-if="hit.snippet" class="item-text">
            …{{ hit.snippet.before }}<mark>{{ hit.snippet.match }}</mark>{{ hit.snippet.after }}…
          </span>
        </button>
      </li>
    </ol>
  </aside>
</template>
```

**`Workspace.vue`**

```vue
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { Stage } from '@embedpdf/vue/stage';
import { RenderLayer } from '@embedpdf/vue/render';
import { SearchLayer } from '@embedpdf/vue/search';
import { useShell } from '@embedpdf/vue/shell';
import NotesPanel from './NotesPanel.vue';
import PanelButton from './PanelButton.vue';
import SearchPanel from './SearchPanel.vue';

const shell = useShell();
// Your app's own data: it stays when the panel closes.
const notes = ref('');

// The search panel is open when the document is.
onMounted(() => shell.open('search', { exclusive: 'right' }));
</script>

<template>
  <div class="toolbar">
    <PanelButton id="search" label="Search" />
    <PanelButton id="notes" label="Notes" />
  </div>
  <div class="workspace">
    <Stage class="stage">
      <template #page>
        <RenderLayer />
        <SearchLayer />
      </template>
    </Stage>
    <SearchPanel />
    <NotesPanel v-model="notes" />
  </div>
</template>
```

## 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.vue`**

```vue
<script setup lang="ts">
import { useSurface } from '@embedpdf/vue/shell';
import CommentList from './CommentList.vue';

const { props } = useSurface('comments'); // { focus: … }
</script>

<template>
  <CommentList :focus="props.focus" />
</template>
```

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

**`App.vue`**

```vue
<script setup lang="ts">
import { DocumentGate, Viewer } from '@embedpdf/vue/runtime';
import type { OpenInput } from '@embedpdf/vue/runtime';
import { stagePlugin } from '@embedpdf/vue/stage';
import { renderPlugin } from '@embedpdf/vue/render';
import { searchPlugin } from '@embedpdf/vue/search';
import { shellPlugin } from '@embedpdf/vue/shell';
import { localEngine } from '@embedpdf/engine';
import Workspace from './Workspace.vue';

import '../props.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), searchPlugin(), shellPlugin()];

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()) };
};
</script>

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: ebook }]">
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <Workspace />
    </DocumentGate>
  </Viewer>
</template>
```

**`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);
}
```

**`MatchPanel.vue`**

```vue
<script setup lang="ts">
import { computed } from 'vue';
import { useSearch, useSearchHits } from '@embedpdf/vue/search';
import { useShell, useSurface } from '@embedpdf/vue/shell';

// The panel shows the match its props name, and steps to the next one without reopening.
const { isOpen, props, close } = useSurface('match');
const shell = useShell();
const search = useSearch();
const hits = useSearchHits();
const index = computed(() => (typeof props.value.index === 'number' ? props.value.index : 0));
const hit = computed(() => hits.value[index.value]);

function show(next: number) {
  shell.updateSurfaceProps('match', { index: next });
  search.goToHit(hits.value[next]);
}
</script>

<template>
  <aside v-if="isOpen" class="panel" aria-label="Match">
    <header class="panel-header">
      <h3 class="panel-title">Match {{ index + 1 }} of {{ hits.length }}</h3>
      <button type="button" class="close" aria-label="Close" @click="close()">×</button>
    </header>
    <template v-if="hit">
      <p class="page">Page {{ hit.pageIndex + 1 }}</p>
      <p v-if="hit.snippet" class="snippet">
        …{{ hit.snippet.before }}<mark>{{ hit.snippet.match }}</mark>{{ hit.snippet.after }}…
      </p>
    </template>
    <p v-else 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 >= hits.length - 1"
        @click="show(index + 1)"
      >
        Next →
      </button>
    </div>
  </aside>
</template>
```

**`Workspace.vue`**

```vue
<script setup lang="ts">
import { onMounted } from 'vue';
import { Stage } from '@embedpdf/vue/stage';
import { RenderLayer } from '@embedpdf/vue/render';
import { SearchLayer, useSearch, useSearchHits } from '@embedpdf/vue/search';
import type { SearchHit } from '@embedpdf/vue/search';
import { useShell } from '@embedpdf/vue/shell';
import MatchPanel from './MatchPanel.vue';

const shell = useShell();
const search = useSearch();
const hits = useSearchHits();

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

// A click on a match opens it in the panel.
function openMatch(hit: SearchHit) {
  shell.open('match', { exclusive: 'right', props: { index: hits.value.indexOf(hit) } });
}
</script>

<template>
  <div class="workspace">
    <Stage class="stage">
      <template #page>
        <RenderLayer />
        <SearchLayer @hit-click="openMatch" />
      </template>
    </Stage>
    <MatchPanel />
  </div>
</template>
```

## 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 `useSurfaceFocus(id, ref)`:

**`panel-focus.vue`**

```vue
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { useSurfaceFocus } from '@embedpdf/vue/shell';

const field = useTemplateRef<HTMLInputElement>('field');
// The cursor goes in the field when the panel asks for it, its text selected.
useSurfaceFocus('search', field, { select: true });
</script>

<template>
  <input ref="field" placeholder="Search" />
</template>
```

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. `select: true` 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.

**`App.vue`**

```vue
<script setup lang="ts">
import { DocumentGate, Viewer } from '@embedpdf/vue/runtime';
import type { OpenInput } from '@embedpdf/vue/runtime';
import { stagePlugin } from '@embedpdf/vue/stage';
import { renderPlugin } from '@embedpdf/vue/render';
import { interactionPlugin } from '@embedpdf/vue/interaction';
import { selectionPlugin } from '@embedpdf/vue/selection';
import { shellPlugin } from '@embedpdf/vue/shell';
import { localEngine } from '@embedpdf/engine';
import Workspace from './Workspace.vue';

import '../menus.css';

const engine = localEngine();
const plugins = [
  stagePlugin(),
  renderPlugin(),
  interactionPlugin(),
  selectionPlugin(),
  shellPlugin(),
];

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()) };
};
</script>

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: ebook }]">
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <Workspace />
    </DocumentGate>
  </Viewer>
</template>
```

**`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);
}
```

**`ViewMenu.vue`**

```vue
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useInteraction, useInteractionState } from '@embedpdf/vue/interaction';
import { useShell, useShellState } from '@embedpdf/vue/shell';

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.
const shell = useShell();
const interaction = useInteraction();
const { openMenus } = useShellState();
const { activeToolId } = useInteractionState();
const bar = ref<HTMLDivElement | null>(null);
const anyOpen = computed(() => openMenus.value.length > 0);

// A press outside the menus, or Escape, closes all of them.
watch(
  anyOpen,
  (open, _previous, onCleanup) => {
    if (!open) return;
    const outside = (event: PointerEvent) => {
      if (!bar.value?.contains(event.target as Node)) shell.closeAllMenus();
    };
    const escape = (event: KeyboardEvent) => {
      if (event.key === 'Escape') shell.closeAllMenus();
    };
    document.addEventListener('pointerdown', outside, true);
    document.addEventListener('keydown', escape);
    onCleanup(() => {
      document.removeEventListener('pointerdown', outside, true);
      document.removeEventListener('keydown', escape);
    });
  },
  { immediate: true },
);

function choose(toolId: string) {
  interaction.activateTool(toolId);
  shell.closeAllMenus();
}
</script>

<template>
  <div class="toolbar">
    <div ref="bar" class="menubar">
      <button
        type="button"
        class="button"
        aria-haspopup="menu"
        :aria-expanded="openMenus.includes('view')"
        @click="shell.toggleMenu('view')"
      >
        View ▾
      </button>
      <div v-if="openMenus.includes('view')" class="menu" role="menu">
        <button
          type="button"
          role="menuitem"
          class="menu-item"
          aria-haspopup="menu"
          :aria-expanded="openMenus.includes('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>
        <div v-if="openMenus.includes('view-tool')" class="menu submenu" role="menu">
          <button
            v-for="tool in TOOLS"
            :key="tool.id"
            type="button"
            role="menuitemradio"
            :aria-checked="tool.id === activeToolId"
            class="menu-item"
            @click="choose(tool.id)"
          >
            {{ tool.label }}
          </button>
        </div>
      </div>
    </div>
    <output class="readout">Open menus: {{ anyOpen ? openMenus.join(' › ') : 'none' }}</output>
  </div>
</template>
```

**`Workspace.vue`**

```vue
<script setup lang="ts">
import { onMounted } from 'vue';
import { Stage } from '@embedpdf/vue/stage';
import { RenderLayer } from '@embedpdf/vue/render';
import { SelectionLayer } from '@embedpdf/vue/selection';
import { useShell } from '@embedpdf/vue/shell';
import ViewMenu from './ViewMenu.vue';

const shell = useShell();

// The menu and its submenu are open when the document is.
onMounted(() => {
  shell.openMenu('view');
  shell.openMenu('view-tool');
});
</script>

<template>
  <ViewMenu />
  <Stage class="stage">
    <template #page>
      <RenderLayer />
      <SelectionLayer />
    </template>
  </Stage>
</template>
```

## Remember the layout

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

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

**`App.vue`**

```vue
<script setup lang="ts">
import { DocumentGate, Viewer } from '@embedpdf/vue/runtime';
import type { OpenInput } from '@embedpdf/vue/runtime';
import { stagePlugin } from '@embedpdf/vue/stage';
import { renderPlugin } from '@embedpdf/vue/render';
import { shellPlugin } from '@embedpdf/vue/shell';
import { localEngine } from '@embedpdf/engine';
import Workspace from './Workspace.vue';

import '../remember.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), shellPlugin()];

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()) };
};
</script>

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: ebook }]">
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <Workspace />
    </DocumentGate>
  </Viewer>
</template>
```

**`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);
}
```

**`layout.ts`**

```typescript
import type { ShellSnapshot } from '@embedpdf/vue/shell';

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

export const saveLayout = (snapshot: ShellSnapshot) => {
  try {
    localStorage.setItem('panels', JSON.stringify(snapshot));
  } catch {
    // Not remembered this time.
  }
};
```

**`NotesPanel.vue`**

```vue
<script setup lang="ts">
import { useSurface } from '@embedpdf/vue/shell';

const { isOpen } = useSurface('notes');
</script>

<template>
  <aside v-if="isOpen" class="panel" aria-label="Notes">
    <h3 class="panel-title">Notes</h3>
    <textarea class="notes" aria-label="Notes" placeholder="Your notes on this document…" />
  </aside>
</template>
```

**`PanelButton.vue`**

```vue
<script setup lang="ts">
import { useSurface } from '@embedpdf/vue/shell';

const props = defineProps<{ id: string; label: string; side: 'left' | 'right' }>();
const { isOpen, toggle } = useSurface(() => props.id);
</script>

<template>
  <button
    type="button"
    class="button"
    :aria-pressed="isOpen"
    @click="toggle({ exclusive: side })"
  >
    {{ label }}
  </button>
</template>
```

**`TipsPanel.vue`**

```vue
<script setup lang="ts">
import { useSurface } from '@embedpdf/vue/shell';

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

const { isOpen } = useSurface('tips');
</script>

<template>
  <aside v-if="isOpen" class="panel" aria-label="Tips">
    <h3 class="panel-title">Tips</h3>
    <ul class="tips">
      <li v-for="tip in TIPS" :key="tip">{{ tip }}</li>
    </ul>
  </aside>
</template>
```

**`Workspace.vue`**

```vue
<script setup lang="ts">
import { computed, onMounted, shallowRef } from 'vue';
import { Stage } from '@embedpdf/vue/stage';
import { RenderLayer } from '@embedpdf/vue/render';
import { useShell, useShellState } from '@embedpdf/vue/shell';
import type { ShellSnapshot } from '@embedpdf/vue/shell';
import NotesPanel from './NotesPanel.vue';
import PanelButton from './PanelButton.vue';
import TipsPanel from './TipsPanel.vue';
import { loadLayout, saveLayout } from './layout';

const shell = useShell();
const { openSurfaces } = useShellState();
const saved = shallowRef<ShellSnapshot | null>(loadLayout());

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

function save() {
  const layout = shell.getSnapshot();
  saveLayout(layout);
  saved.value = layout;
}

function restore() {
  if (saved.value) shell.applySnapshot(saved.value);
}

const open = computed(() => openSurfaces.value.map((surface) => surface.id).join(', '));
</script>

<template>
  <div class="toolbar">
    <PanelButton id="tips" label="Tips" side="left" />
    <PanelButton id="notes" label="Notes" side="right" />
    <span class="spacer" />
    <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">
    <TipsPanel />
    <Stage class="stage">
      <template #page><RenderLayer /></template>
    </Stage>
    <NotesPanel />
  </div>
</template>
```

## Methods

Everything `useShell()` 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, `useSurface(id)` gives you one panel: `{ isOpen, props, open, close, toggle }`.

## State

Everything `useShellState()` gives you, as refs: your template updates when a value 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.vue`**

```vue
<script setup lang="ts">
import { useShellEvent } from '@embedpdf/vue/shell';
import { analytics } from './analytics';

useShellEvent(
  (shell) => shell.onSurfaceOpened,
  ({ id }) => analytics.track('panel', { id }),
);
</script>

<template>
  <!-- your toolbar, panels and pages -->
</template>
```

| Event             | Fires when               | Carries       |
| :---------------- | :----------------------- | :------------ |
| `onSurfaceOpened` | A panel or dialog opened | `id`, `props` |
| `onSurfaceClosed` | A panel or dialog closed | `id`          |
| `onMenuOpened`    | A menu opened            | `id`          |
| `onMenuClosed`    | A menu closed            | `id`          |

## Good to know

### No document yet

The panel composables 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 `<DocumentGate>` can use them.
