# Filmstrip and sheet

Both are optional add-ons with their own entries, so their code loads only when you use them. Turn one on with the `filmstrip` and `sheet` options of `createFluidbox`, the `Filmstrip` and `Sheet` parts in each framework, or a `<fluidbox-filmstrip>` or `<fluidbox-sheet>` child of `<fluidbox-gallery>`.

```ts
import { createFluidbox } from "@ogdakke/fluidbox";
import "@ogdakke/fluidbox/filmstrip";
import "@ogdakke/fluidbox/sheet";
import "@ogdakke/fluidbox/styles.css";
import "@ogdakke/fluidbox/filmstrip.css";
import "@ogdakke/fluidbox/sheet.css";

const fluidbox = createFluidbox({
  items: photos,
  filmstrip: true,
  sheet: {
    render({ item, target }) {
      target.textContent = item.id ?? "";
    },
  },
});
```

The fluidbox loads an add-on's entry the first time you turn it on, and an open waits for it. Import the entry yourself, as above, to have it in the page's bundle from the start instead. The entries are safe to import on the server.

## Filmstrip

The filmstrip is a strip of thumbnails for a gallery of more than one item. A tap moves to that item, a drag or a wheel scrubs through them, and the strip follows swipes, arrow keys and `open(target)`. It renders only the thumbnails in view, so it works for lazy lists of any size. Its styles are in `@ogdakke/fluidbox/filmstrip.css`.

The strip is viewer content like your own. It mounts beside the viewer's layer, in `[data-fluidbox-addons]`, so a redraw of the layer, or a viewer renderer that clears its `target`, leaves it be. It reveals and dismisses with the layer, as `data-fluidbox-chrome`, and floats over the slides. `filmstrip.css` runs it along the bottom and keeps the photo above it by `--fluidbox-filmstrip-space`. Your CSS on `[data-fluidbox-filmstrip-dock]` puts it anywhere else, over the photo or beside it. Set `--fluidbox-filmstrip-space: 0px` when the photo should run under it.

`--fluidbox-filmstrip-axis: y` on the dialog runs it as a column, down the left unless you move it. The add-on reads the axis from CSS, so a media query can turn a phone's row into a wide screen's column. Its listbox carries `aria-orientation`, and a wheel turned either way scrolls it along.

```css
@media (min-width: 900px) {
  [data-fluidbox-dialog] {
    --fluidbox-filmstrip-axis: y;
  }
}
```

`filmstrip: true` draws each item's thumbnail. An object, `FluidboxFilmstripOptions`, changes that:

| Option | Type | What it does |
| --- | --- | --- |
| `render` | `FluidboxRenderer<TItem>` | Fill each thumbnail button yourself instead of the default image. |

`render` gets `{ item, index, target, signal }`. `target` is the thumbnail `<button>`. When the button scrolls out of the rendered window, `signal` aborts and the function you return runs. By default each button holds the item's `thumbnail`, else the full image or the video's poster.

```ts
import { createFluidbox } from "@ogdakke/fluidbox";
import "@ogdakke/fluidbox/filmstrip";

const fluidbox = createFluidbox({
  items: photos,
  filmstrip: {
    render({ index, target }) {
      target.textContent = String(index + 1);
    },
  },
});
```

Each button is a `role="option"` in a listbox named by the `filmstripList` [label](https://fluidbox.dwe.workers.dev/docs/api.md#labels). It has `data-fluidbox-filmstrip-item`, `data-index`, `aria-selected` and `data-fluidbox-selected` on the current item, and the item's alt text or title as its name. With a thumbnail focused, the arrow keys along the strip step through the items, Home and End go to the ends, and focus follows the selection. The strip carries `data-fluidbox-owns-input`, so its touches, wheel and keys scrub the strip rather than move or close the viewer. A move from the strip has the reason of its input, `"pointer"`, `"wheel"` or `"keyboard"`, and a scrub through it is a [`scrub`](https://fluidbox.dwe.workers.dev/docs/javascript.md#commands) move.

`render` runs for each button as it enters the strip's rendered window, and its `signal` aborts as the button leaves. The framework `Filmstrip` parts render through it. Put the part in the root, with a child that draws one thumbnail. See the framework pages for each one's form.

#### React

`filmstrip.tsx`

```tsx
import { itemLabel } from "@ogdakke/fluidbox";
import { Fluidbox } from "@ogdakke/fluidbox/react";
import "@ogdakke/fluidbox/filmstrip.css";
import { photos } from "./data";

function Thumbnails() {
  return (
    <div className="pattern-grid">
      {photos.map((photo) => (
        <Fluidbox.Trigger
          key={photo.src}
          item={photo}
          className="pattern-thumb"
        >
          <img src={photo.src} alt={itemLabel(photo)} />
        </Fluidbox.Trigger>
      ))}
    </div>
  );
}

export function Filmstrip() {
  return (
    <>
      <Fluidbox.Root items={photos}>
        <Thumbnails />
        {/* Being there turns the filmstrip on. */}
        <Fluidbox.Filmstrip />
      </Fluidbox.Root>
      <Fluidbox.Root items={photos}>
        <Thumbnails />
        {/* Your own thumbnails. The selected button has data-fluidbox-selected. */}
        <Fluidbox.Filmstrip>
          {({ index }) => (
            <span className="pattern-filmstrip-thumb">
              {index + 1}
            </span>
          )}
        </Fluidbox.Filmstrip>
      </Fluidbox.Root>
    </>
  );
}
```

#### Vue

`Filmstrip.vue`

```vue
<script setup lang="ts">
import { itemLabel } from "@ogdakke/fluidbox";
import { Fluidbox } from "@ogdakke/fluidbox/vue";
import "@ogdakke/fluidbox/filmstrip.css";
import { photos } from "./data";
</script>

<template>
  <Fluidbox.Root :items="photos">
    <div class="pattern-grid">
      <Fluidbox.Trigger
        v-for="photo in photos"
        :key="photo.id"
        :item="photo"
        class="pattern-thumb"
      >
        <img :src="photo.src" :alt="itemLabel(photo)" />
      </Fluidbox.Trigger>
    </div>
    <Fluidbox.Filmstrip />
  </Fluidbox.Root>

  <Fluidbox.Root :items="photos">
    <div class="pattern-grid">
      <Fluidbox.Trigger
        v-for="photo in photos"
        :key="photo.id"
        :item="photo"
        class="pattern-thumb"
      >
        <img :src="photo.src" :alt="itemLabel(photo)" />
      </Fluidbox.Trigger>
    </div>
    <!-- Your own thumbnails. The selected button has data-fluidbox-selected. -->
    <Fluidbox.Filmstrip v-slot="{ index }">
      <span class="pattern-filmstrip-thumb">{{ index + 1 }}</span>
    </Fluidbox.Filmstrip>
  </Fluidbox.Root>
</template>
```

#### Solid

`filmstrip.tsx`

```tsx
import { For } from "solid-js";
import { itemLabel } from "@ogdakke/fluidbox";
import { Fluidbox } from "@ogdakke/fluidbox/solid";
import "@ogdakke/fluidbox/filmstrip.css";
import { photos } from "./data";

function Thumbnails() {
  return (
    <div class="pattern-grid">
      <For each={photos}>
        {(photo) => (
          <Fluidbox.Trigger item={photo} class="pattern-thumb">
            <img src={photo.src} alt={itemLabel(photo)} />
          </Fluidbox.Trigger>
        )}
      </For>
    </div>
  );
}

export function Filmstrip() {
  return (
    <>
      <Fluidbox.Root items={photos}>
        <Thumbnails />
        <Fluidbox.Filmstrip />
      </Fluidbox.Root>

      <Fluidbox.Root items={photos}>
        <Thumbnails />
        {/* Your own thumbnails. The selected button has data-fluidbox-selected. */}
        <Fluidbox.Filmstrip>
          {(thumb) => (
            <span class="pattern-filmstrip-thumb">
              {thumb.index + 1}
            </span>
          )}
        </Fluidbox.Filmstrip>
      </Fluidbox.Root>
    </>
  );
}
```

#### Angular

`filmstrip.ts`

```ts
import { Component } from "@angular/core";
import { itemLabel } from "@ogdakke/fluidbox";
import { FluidboxModule } from "@ogdakke/fluidbox/angular";
import "@ogdakke/fluidbox/filmstrip.css";
import { photos } from "./data";

@Component({
  selector: "pattern-filmstrip",
  standalone: true,
  imports: [FluidboxModule],
  template: `
    <div fluidboxRoot [items]="photos" class="pattern-grid">
      @for (photo of photos; track photo.id) {
        <button [fluidboxTrigger]="photo" class="pattern-thumb">
          <img [src]="photo.src" [alt]="label(photo)" />
        </button>
      }
      <ng-container fluidboxFilmstrip />
    </div>
    <div fluidboxRoot [items]="photos" class="pattern-grid">
      @for (photo of photos; track photo.id) {
        <button [fluidboxTrigger]="photo" class="pattern-thumb">
          <img [src]="photo.src" [alt]="label(photo)" />
        </button>
      }
      <!-- Your own thumbnails. The selected button has data-fluidbox-selected. -->
      <ng-template fluidboxFilmstrip let-index="index">
        <span class="pattern-filmstrip-thumb">{{ index + 1 }}</span>
      </ng-template>
    </div>
  `,
})
export class Filmstrip {
  readonly photos = photos;
  readonly label = itemLabel;
}
```

#### Elements

`filmstrip.html`

```html
<fluidbox-gallery class="pattern-grid">
  <fluidbox-item item-id="photo-aurora">
    <button slot="trigger" type="button" class="pattern-thumb">
      <img src="/art/aurora.svg" alt="Aurora over the tundra" />
    </button>
    <template slot="content">
      <img src="/art/aurora.svg" alt="Aurora over the tundra" width="1100" height="1650" />
    </template>
  </fluidbox-item>
  <fluidbox-item item-id="photo-ridge">
    <button slot="trigger" type="button" class="pattern-thumb">
      <img src="/art/ridge.svg" alt="Night ridge" />
    </button>
    <template slot="content">
      <img src="/art/ridge.svg" alt="Night ridge" width="1200" height="1500" />
    </template>
  </fluidbox-item>
  <fluidbox-item item-id="photo-fjord">
    <button slot="trigger" type="button" class="pattern-thumb">
      <img src="/art/fjord.svg" alt="Fjord in the blue hour" />
    </button>
    <template slot="content">
      <img src="/art/fjord.svg" alt="Fjord in the blue hour" width="1200" height="1600" />
    </template>
  </fluidbox-item>
  <fluidbox-item item-id="photo-lagoon">
    <button slot="trigger" type="button" class="pattern-thumb">
      <img src="/art/lagoon.svg" alt="Lagoon after sunset" />
    </button>
    <template slot="content">
      <img src="/art/lagoon.svg" alt="Lagoon after sunset" width="1800" height="1200" />
    </template>
  </fluidbox-item>
  <fluidbox-filmstrip></fluidbox-filmstrip>
</fluidbox-gallery>
<fluidbox-gallery class="pattern-grid numbered-strip">
  <fluidbox-item item-id="photo-aurora">
    <button slot="trigger" type="button" class="pattern-thumb">
      <img src="/art/aurora.svg" alt="Aurora over the tundra" />
    </button>
    <template slot="content">
      <img src="/art/aurora.svg" alt="Aurora over the tundra" width="1100" height="1650" />
    </template>
  </fluidbox-item>
  <fluidbox-item item-id="photo-ridge">
    <button slot="trigger" type="button" class="pattern-thumb">
      <img src="/art/ridge.svg" alt="Night ridge" />
    </button>
    <template slot="content">
      <img src="/art/ridge.svg" alt="Night ridge" width="1200" height="1500" />
    </template>
  </fluidbox-item>
  <fluidbox-item item-id="photo-fjord">
    <button slot="trigger" type="button" class="pattern-thumb">
      <img src="/art/fjord.svg" alt="Fjord in the blue hour" />
    </button>
    <template slot="content">
      <img src="/art/fjord.svg" alt="Fjord in the blue hour" width="1200" height="1600" />
    </template>
  </fluidbox-item>
  <fluidbox-item item-id="photo-lagoon">
    <button slot="trigger" type="button" class="pattern-thumb">
      <img src="/art/lagoon.svg" alt="Lagoon after sunset" />
    </button>
    <template slot="content">
      <img src="/art/lagoon.svg" alt="Lagoon after sunset" width="1800" height="1200" />
    </template>
  </fluidbox-item>
  <fluidbox-filmstrip></fluidbox-filmstrip>
</fluidbox-gallery>
```

`filmstrip.ts`

```ts
import "@ogdakke/fluidbox/filmstrip.css";

export function filmstrip(root: HTMLElement) {
  // Your own thumbnails. The selected button has data-fluidbox-selected.
  const strip = root.querySelector(".numbered-strip")!.querySelector("fluidbox-filmstrip")!;
  strip.render = ({ index, target }) => {
    const thumb = document.createElement("span");
    thumb.className = "pattern-filmstrip-thumb";
    thumb.textContent = String(index + 1);
    target.append(thumb);
  };
}
```

#### JavaScript

`filmstrip.ts`

```ts
import { createFluidbox, itemLabel, type FluidboxOptions } from "@ogdakke/fluidbox";
import "@ogdakke/fluidbox/filmstrip";
import "@ogdakke/fluidbox/filmstrip.css";
import { photos } from "./data";

function gallery(root: HTMLElement, filmstrip: FluidboxOptions["filmstrip"]) {
  const fluidbox = createFluidbox({ items: photos, filmstrip });
  const grid = document.createElement("div");
  grid.className = "pattern-grid";
  photos.forEach((photo, index) => {
    const button = document.createElement("button");
    button.type = "button";
    button.className = "pattern-thumb";
    button.append(Object.assign(new Image(), { src: photo.src, alt: itemLabel(photo) }));
    grid.append(button);
    fluidbox.bind(button, index);
  });
  root.append(grid);
  return () => {
    fluidbox.destroy();
    grid.remove();
  };
}

export function mountFilmstrip(root: HTMLElement) {
  const standard = gallery(root, true);
  // Your own thumbnails. The selected button has data-fluidbox-selected.
  const custom = gallery(root, {
    render({ index, target }) {
      const thumb = document.createElement("span");
      thumb.className = "pattern-filmstrip-thumb";
      thumb.textContent = String(index + 1);
      target.append(thumb);
      return () => thumb.remove();
    },
  });

  return () => {
    standard();
    custom();
  };
}
```

On custom elements, a `<fluidbox-filmstrip>` child adds the strip. Set its `render` property to fill the buttons yourself.

## Sheet

The sheet is a panel that rises from the bottom over the photo, for details, comments or a form. The photo and the sheet share one native scroller with a snap point per stop, so swipes, momentum and rubber-banding are the browser's own. When the sheet reaches the photo, it pushes the photo up with a gap between them. The push is layout, with no animation, so the photo keeps up with the fastest swipe. Import `@ogdakke/fluidbox/sheet.css` with it.

`sheet: true` turns it on with the defaults. An object, `FluidboxSheetOptions`, sets them:

| Option | Type | Default | What it does |
| --- | --- | --- | --- |
| `snapPoints` | `FluidboxSheetSnapPoint[]` | `["60%", "content"]` | The stops above the resting one, lowest first. |
| `dismiss` | `"flight" \| "scroll" \| "none"` | `"flight"` | What a swipe down on the photo does at rest. |
| `render` | `FluidboxRenderer<TItem>` | | Fill the sheet for the item on show. |

### Stops

Stop `0` is the sheet at rest, either out of sight or showing `--fluidbox-sheet-peek` of itself. Each snap point, a `FluidboxSheetSnapPoint`, adds a stop above it:

- A number is pixels of sheet showing.
- `"40%"` is a percentage of the viewer's height.
- `"content"` is the sheet's own height, so it sits lower when there is little content.

A sheet is always tall enough for its highest pixel or percentage stop. No stop goes above `--fluidbox-sheet-top` from the top of the screen. Stops closer than 24 pixels count as one.

`dismiss` sets what a swipe down on the photo does while the sheet is at rest. `"flight"` closes the viewer with the usual flight home. A second flick down while the sheet is still lowering goes straight into it. `"scroll"` scrolls the photo off the bottom of the screen and closes, and the backdrop fades as it goes. `"none"` keeps the viewer open, as for a caption that always peeks.

### The sheet handle

With the sheet on, `fluidbox.sheet` is a `FluidboxSheet` that you drive and read:

| Member | What it does |
| --- | --- |
| `stop` | The stop the sheet rests at: `0`, then one per snap point. |
| `set(stop, options?)` | Move to a stop, clamped to the stops there are. `{ animate: false }` jumps. Ignored while the viewer isn't open or the sheet is off. |

Each time the sheet comes to rest at another stop, the fluidbox fires `sheetchange`, a `FluidboxSheetEvent` with `stop` and `reason`. The reason is `"pointer"` for a swipe or a tap, `"wheel"` for a wheel or trackpad, `"keyboard"` for Escape or a key on a stop button, `"api"` for `set()` and for the return to rest as the viewer opens or closes.

```ts
fluidbox.addEventListener("sheetchange", ({ stop, reason }) => {
  console.log(`sheet at stop ${stop}, by ${reason}`);
});
```

In the frameworks, `useFluidboxSheet()` returns `{ stop, set }`, with `stop` in the framework's reactive form. The root takes the event as `onSheetChange`, `@sheetchange` or `(sheetChange)`, and a `SheetStop` part is a button that moves the sheet to its `stop`. On custom elements, `gallery.sheet` is the handle and the gallery fires `sheetchange`.

### The sheet's content

The sheet is a `<section data-fluidbox-sheet data-fluidbox-chrome>` that the runtime makes, named by the `sheet` [label](https://fluidbox.dwe.workers.dev/docs/api.md#labels). `render({ item, index, target, signal })` fills it for the item on show. When the viewer moves to another item, `signal` aborts, the returned function runs, and the runtime empties the section before the next item's render. The framework `Sheet` parts render through it, so their children re-render with the app's state, and they put their own attributes on the section.

`<fluidbox-sheet>` shows the active item's `<template slot="sheet">`, else its own `<template>`. Set its `render` property to draw the sheet yourself. Its `snap-points` and `dismiss` attributes are the options: `<fluidbox-sheet snap-points="40% 90%" dismiss="scroll">`.

Put these inside the sheet element:

| Selector | Part |
| --- | --- |
| `[data-fluidbox-sheet-handle]` | Draws a grab bar. Make it the sheet's first child. |
| `[data-fluidbox-sheet-scroll]` | The part that scrolls once the sheet is at its top stop. Without one, the whole sheet does. |

Below the top stop, a swipe anywhere on the sheet moves the sheet. At the top stop its content scrolls, and a pull down from the top of the content carries on into the sheet. Put padding on an inner element, not on `[data-fluidbox-sheet]`.

#### React

`sheet.tsx`

```tsx
import { itemLabel, type FluidboxItem } from "@ogdakke/fluidbox";
import { Fluidbox, useFluidboxSheet } from "@ogdakke/fluidbox/react";
import "@ogdakke/fluidbox/sheet.css";
import { photos } from "./data";

function Details({ item }: { item: FluidboxItem }) {
  const sheet = useFluidboxSheet();
  return (
    <>
      <h3>{itemLabel(item)}</h3>
      <p>
        Stop <output>{sheet.stop}</output>
      </p>
      <button type="button" onClick={() => sheet.set(2)}>
        Expand
      </button>
      <input placeholder="Add a note" aria-label="Note" />
    </>
  );
}

export function Sheet() {
  return (
    <Fluidbox.Root items={photos}>
      <div className="pattern-grid">
        {photos.map((photo) => (
          <Fluidbox.Trigger
            key={photo.src}
            item={photo}
            className="pattern-thumb"
          >
            <img src={photo.src} alt={itemLabel(photo)} />
          </Fluidbox.Trigger>
        ))}
      </div>
      {/* Stop 0 rests below the photo; stops 1 and 2 raise it to 40% and 90% of the screen. */}
      <Fluidbox.Sheet snapPoints={["40%", "90%"]} className="pattern-sheet">
        {({ item }) => <Details item={item} />}
      </Fluidbox.Sheet>
      <Fluidbox.Viewer>
        <Fluidbox.SheetStop stop={1} className="pattern-sheet-toggle">
          Details
        </Fluidbox.SheetStop>
        <Fluidbox.Controls />
      </Fluidbox.Viewer>
    </Fluidbox.Root>
  );
}
```

#### Vue

`Sheet.vue`

```vue
<script setup lang="ts">
import { itemLabel } from "@ogdakke/fluidbox";
import { Fluidbox } from "@ogdakke/fluidbox/vue";
import "@ogdakke/fluidbox/sheet.css";
import { ref } from "vue";
import { photos } from "./data";

// The stop the sheet rests at: 0 at rest, then one per snap point.
const stop = ref(0);
</script>

<template>
  <Fluidbox.Root :items="photos" @sheetchange="stop = $event.stop">
    <div class="pattern-grid">
      <Fluidbox.Trigger
        v-for="photo in photos"
        :key="photo.id"
        :item="photo"
        class="pattern-thumb"
      >
        <img :src="photo.src" :alt="itemLabel(photo)" />
      </Fluidbox.Trigger>
    </div>
    <Fluidbox.Viewer>
      <Fluidbox.Controls />
      <!-- At stop 0 the sheet is out of sight; this raises it. -->
      <Fluidbox.SheetStop :stop="1" class="pattern-sheet-toggle">
        Details
      </Fluidbox.SheetStop>
    </Fluidbox.Viewer>
    <!-- The sheet's content, for the active item. Its attributes go on the sheet. -->
    <Fluidbox.Sheet v-slot="{ item }" :snap-points="['40%', '90%']" class="pattern-sheet">
      <h3>{{ itemLabel(item) }}</h3>
      <p>
        Stop <output>{{ stop }}</output>
      </p>
      <Fluidbox.SheetStop :stop="2">Expand</Fluidbox.SheetStop>
      <input placeholder="Add a note" aria-label="Note" />
    </Fluidbox.Sheet>
  </Fluidbox.Root>
</template>
```

#### Solid

`sheet.tsx`

```tsx
import { For } from "solid-js";
import { itemLabel, type FluidboxImageItem } from "@ogdakke/fluidbox";
import { Fluidbox, useFluidboxSheet } from "@ogdakke/fluidbox/solid";
import "@ogdakke/fluidbox/sheet.css";
import { photos } from "./data";

function Details(props: { photo: FluidboxImageItem }) {
  const sheet = useFluidboxSheet();
  return (
    <>
      <h3>{itemLabel(props.photo)}</h3>
      <p>
        Stop <output>{sheet.stop()}</output>
      </p>
      <button type="button" onClick={() => sheet.set(2)}>
        Expand
      </button>
      <input placeholder="Add a note" aria-label="Note" />
    </>
  );
}

export function Sheet() {
  return (
    <Fluidbox.Root items={photos}>
      <div class="pattern-grid">
        <For each={photos}>
          {(photo) => (
            <Fluidbox.Trigger item={photo} class="pattern-thumb">
              <img src={photo.src} alt={itemLabel(photo)} />
            </Fluidbox.Trigger>
          )}
        </For>
      </div>
      {/* The sheet's content, for the active item. Its attributes go on the sheet itself. */}
      <Fluidbox.Sheet<FluidboxImageItem> snapPoints={["40%", "90%"]} class="pattern-sheet">
        {({ item }) => <Details photo={item} />}
      </Fluidbox.Sheet>
      <Fluidbox.Viewer>
        {/* Raises the sheet to its first stop. */}
        <Fluidbox.SheetStop stop={1} class="pattern-sheet-toggle">
          Details
        </Fluidbox.SheetStop>
        <Fluidbox.Controls />
      </Fluidbox.Viewer>
    </Fluidbox.Root>
  );
}
```

#### Angular

`sheet.ts`

```ts
import { Component, Input } from "@angular/core";
import { itemLabel } from "@ogdakke/fluidbox";
import {
  FluidboxModule,
  injectFluidboxSheet,
  type FluidboxImageItem,
} from "@ogdakke/fluidbox/angular";
import "@ogdakke/fluidbox/sheet.css";
import { photos } from "./data";

@Component({
  selector: "div[appDetails]",
  standalone: true,
  host: { class: "pattern-sheet" },
  template: `
    <h3>{{ label(photo) }}</h3>
    <p>
      Stop <output>{{ sheet.stop() }}</output>
    </p>
    <button type="button" (click)="sheet.set(2)">Expand</button>
    <input placeholder="Add a note" aria-label="Note" />
  `,
})
export class Details {
  @Input({ required: true }) photo!: FluidboxImageItem;
  readonly sheet = injectFluidboxSheet();
  readonly label = itemLabel;
}

@Component({
  selector: "pattern-sheet",
  standalone: true,
  imports: [FluidboxModule, Details],
  template: `
    <div fluidboxRoot [items]="photos" class="pattern-grid">
      @for (photo of photos; track photo.id) {
        <button [fluidboxTrigger]="photo" class="pattern-thumb">
          <img [src]="photo.src" [alt]="label(photo)" />
        </button>
      }
      <!-- Stop 0 rests below the photo; stops 1 and 2 raise it to 40% and 90% of the screen. -->
      <ng-template fluidboxSheet [snapPoints]="['40%', '90%']" let-photo>
        <div appDetails [photo]="photo"></div>
      </ng-template>
      <ng-template fluidboxViewer>
        <div fluidboxControls></div>
        <!-- A fluidboxSheetStop button moves the sheet to that stop. -->
        <button fluidboxSheetStop="1" class="pattern-sheet-toggle">Details</button>
      </ng-template>
    </div>
  `,
})
export class Sheet {
  readonly photos = photos;
  readonly label = itemLabel;
}
```

#### Elements

`sheet.html`

```html
<fluidbox-gallery class="pattern-grid">
  <fluidbox-item item-id="photo-aurora">
    <button slot="trigger" type="button" class="pattern-thumb">
      <img src="/art/aurora.svg" alt="Aurora over the tundra" />
    </button>
    <template slot="content">
      <img src="/art/aurora.svg" alt="Aurora over the tundra" width="1100" height="1650" />
    </template>
  </fluidbox-item>
  <fluidbox-item item-id="photo-ridge">
    <button slot="trigger" type="button" class="pattern-thumb">
      <img src="/art/ridge.svg" alt="Night ridge" />
    </button>
    <template slot="content">
      <img src="/art/ridge.svg" alt="Night ridge" width="1200" height="1500" />
    </template>
  </fluidbox-item>
  <fluidbox-item item-id="photo-fjord">
    <button slot="trigger" type="button" class="pattern-thumb">
      <img src="/art/fjord.svg" alt="Fjord in the blue hour" />
    </button>
    <template slot="content">
      <img src="/art/fjord.svg" alt="Fjord in the blue hour" width="1200" height="1600" />
    </template>
  </fluidbox-item>
  <fluidbox-item item-id="photo-lagoon">
    <button slot="trigger" type="button" class="pattern-thumb">
      <img src="/art/lagoon.svg" alt="Lagoon after sunset" />
    </button>
    <template slot="content">
      <img src="/art/lagoon.svg" alt="Lagoon after sunset" width="1800" height="1200" />
    </template>
  </fluidbox-item>
  <template slot="viewer">
    <fluidbox-controls></fluidbox-controls>
    <button
      type="button"
      class="pattern-sheet-toggle"
      data-fluidbox-sheet-stop="1"
    >
      Details
    </button>
  </template>
  <fluidbox-sheet snap-points="40% 90%">
    <template>
      <div class="pattern-sheet">
        <h3></h3>
        <p>Stop <output name="stop">0</output></p>
        <button type="button" data-fluidbox-sheet-stop="2">Expand</button>
        <input type="text" aria-label="Note" placeholder="Add a note" />
      </div>
    </template>
  </fluidbox-sheet>
</fluidbox-gallery>
```

`sheet.ts`

```ts
import { itemLabel } from "@ogdakke/fluidbox";
import "@ogdakke/fluidbox/sheet.css";

export function sheet(root: HTMLElement) {
  const gallery = root.querySelector("fluidbox-gallery")!;
  const sheet = gallery.querySelector("fluidbox-sheet")!;
  const content = sheet.querySelector("template")!;
  // Fill the sheet for the active item; without this it shows the template as is.
  sheet.render = ({ item, target, signal }) => {
    target.append(content.content.cloneNode(true));
    target.querySelector("h3")!.textContent = itemLabel(item);
    const stop = target.querySelector<HTMLOutputElement>('output[name="stop"]')!;
    stop.value = String(gallery.sheet!.stop);
    gallery.addEventListener("sheetchange", (event) => (stop.value = String(event.stop)), {
      signal,
    });
  };
}
```

#### JavaScript

`sheet.ts`

```ts
import {
  createFluidbox,
  itemLabel,
  type Fluidbox,
  type FluidboxImageItem,
} from "@ogdakke/fluidbox";
import "@ogdakke/fluidbox/sheet";
import "@ogdakke/fluidbox/sheet.css";
import { photos } from "./data";

export function mountSheet(root: HTMLElement) {
  const fluidbox: Fluidbox<FluidboxImageItem> = createFluidbox({
    items: photos,
    sheet: {
      snapPoints: ["40%", "90%"],
      // Fills the sheet for the active item.
      render({ item, target, signal }) {
        const sheet = fluidbox.sheet!;
        target.innerHTML = `
          <div class="pattern-sheet">
            <h3></h3>
            <p>Stop <output name="stop"></output></p>
            <button type="button" data-action="expand">Expand</button>
            <input placeholder="Add a note" aria-label="Note" />
          </div>`;
        target.querySelector("h3")!.textContent = itemLabel(item);
        target
          .querySelector('[data-action="expand"]')!
          .addEventListener("click", () => sheet.set(2));
        const stop = target.querySelector<HTMLOutputElement>('output[name="stop"]')!;
        stop.value = String(sheet.stop);
        fluidbox.addEventListener("sheetchange", (event) => (stop.value = String(event.stop)), {
          signal,
        });
      },
    },
    // A button marked data-fluidbox-sheet-stop moves the sheet to that stop.
    viewer({ target }) {
      target.insertAdjacentHTML(
        "beforeend",
        `<button type="button" class="pattern-sheet-toggle" data-fluidbox-sheet-stop="1">Details</button>`,
      );
    },
  });

  const grid = document.createElement("div");
  grid.className = "pattern-grid";
  photos.forEach((photo, index) => {
    const button = document.createElement("button");
    button.type = "button";
    button.className = "pattern-thumb";
    button.append(Object.assign(new Image(), { src: photo.src, alt: itemLabel(photo) }));
    grid.append(button);
    fluidbox.bind(button, index);
  });
  root.append(grid);

  return () => {
    fluidbox.destroy();
    grid.remove();
  };
}
```

### Stop buttons

Any element in the viewer with `data-fluidbox-sheet-stop="N"` moves the sheet to stop `N` when clicked, such as a "Details" button in the viewer's layer or a reply count. Clicked again at that stop, it lowers the sheet to `0`. This works in every entry point, as plain markup. The runtime keeps `aria-expanded` on every stop button, and moves focus into the sheet once it settles at a raised stop. When the sheet goes out of sight, it becomes `inert`, and focus inside it goes back to the button or the dialog.

```html
<button type="button" data-fluidbox-sheet-stop="1">Details</button>
```

With the sheet raised, Escape and a tap above the sheet lower it instead of closing the viewer. Keys typed in the sheet's fields stay there, so the arrow keys don't change the slide. A tap in a field focuses it without the page panning, and the sheet makes room for the software keyboard. Zoom works only with the sheet at rest.

### sheet.css

`@ogdakke/fluidbox/sheet.css` holds the sheet's layout and its variables. Set them on `[data-fluidbox-dialog]`. The defaults have no weight, so any selector wins. The runtime reads the ones that say so as they change, so a media query can set them too.

| Property | Default | What it does |
| --- | --- | --- |
| `--fluidbox-sheet-enabled` | `1` | `0` turns the sheet off, as for wide screens with a sidebar instead. The runtime reads it. |
| `--fluidbox-sheet-photo` | `push` | `push` slides the photo up ahead of the sheet; `fit` shrinks it into the room above. The runtime reads it. |
| `--fluidbox-sheet-peek` | `0px` | How much of the sheet shows at rest. Also sets `--fluidbox-stage-bottom`. The runtime reads it. |
| `--fluidbox-sheet-gap` | `12px` | Space between the photo and the sheet. The runtime reads it. |
| `--fluidbox-sheet-fit-inset` | `0px` | With `fit`, space the photo keeps clear above and beside it as it shrinks. The runtime reads it. |
| `--fluidbox-sheet-fit-limit` | `55%` | With `fit`, how much the sheet covers before the photo stops shrinking and rises: a length, or a percentage of the stage. The runtime reads it. |
| `--fluidbox-sheet-top` | `calc(env(safe-area-inset-top, 0px) + 56px)` | Space above the sheet at its tallest. |
| `--fluidbox-sheet-radius` | `18px` | The sheet's top corners. |
| `--fluidbox-sheet-max-width` | `640px` | The sheet's widest. |
| `--fluidbox-sheet-background` | `Canvas` | The sheet's background. |
| `--fluidbox-sheet-color` | `CanvasText` | The sheet's text colour. |
| `--fluidbox-sheet-fade` | `1` | How much the photo fades between the first stop and the top one. `0` keeps it. |

As the sheet moves, these variables on the overlay follow its scroll. Use them for your own effects:

| Property | On | Value |
| --- | --- | --- |
| `--fluidbox-sheet-visible` | Overlay | Pixels of the sheet showing. |
| `--fluidbox-sheet-progress` | Overlay | `0` at rest to `1` at the first stop. |
| `--fluidbox-sheet-expand` | Overlay | `0` at the first stop to `1` at the top stop. |
| `--fluidbox-sheet-dismiss` | Overlay | `1` with the photo scrolled away to `0` at rest, with `dismiss: "scroll"`. |

The dialog gets `data-sheet` with the stop number, `data-sheet-raised` above stop `0`, `data-sheet-expanded` at the top stop, `data-sheet-peeking` while the peek is above zero and `data-sheet-off` while the sheet is turned off. The overlay gets `data-sheet-dismiss` with the `dismiss` option.

```css
[data-fluidbox-dialog] .caption {
  opacity: clamp(0, 1 - var(--fluidbox-sheet-progress) * 2.5, 1);
}

@media (min-width: 900px) {
  [data-fluidbox-dialog]:has(.replies) {
    --fluidbox-sheet-enabled: 0;
    --fluidbox-stage-right: 360px;
  }
}
```

In a browser without scroll-driven animations, the sheet still scrolls, snaps and pushes the photo. With `fit`, the photo keeps its size behind it.
