Navigation and a persistent notebook
This demo keeps a notebook island while replacing an ordinary page and its counter. Follow installation, then run the demo application and open /navigation.
The notebook draft is writable state. Its action counts interactions without saving anything. Both destination mounts and later updates check the current session role. The application's FormHelper renders the controls, and the application stylesheet owns the progress bar.
<?php
declare(strict_types=1);
namespace TestApp\Island;
use Cake\Http\Exception\ForbiddenException;
use Interact\Attribute\Action;
use Interact\Attribute\Prop;
use Interact\Attribute\State;
use Interact\Island;
use Interact\IslandContext;
/**
* Keeps an explicit layout draft alive while page content changes around it.
* Authorization still runs for each destination mount and every subsequent action.
*/
class NavigationNotebookIsland extends Island
{
#[Prop]
public string $edition = 'shared';
#[State(writable: true)]
public string $note = '';
#[State]
public int $saved = 0;
#[State(writable: true, url: 'notebook', history: true)]
public string $filter = '';
/**
* Keep a browser's existing signed snapshot subject to current session permissions.
*/
public function authorize(IslandContext $context): void
{
/** @var mixed $identity */
$identity = $context->request->getAttribute('identity');
if (!is_array($identity) || ($identity['role'] ?? null) !== 'editor') {
throw new ForbiddenException('Notebook access requires an editor.');
}
}
/**
* Count explicit application interactions without implying database persistence.
*/
#[Action]
public function remember(): void
{
$this->saved++;
}
/**
* Request the same enhanced navigation used by ordinary marked links.
*/
#[Action]
public function openDetails(): void
{
$this->redirect('/navigation/details', navigate: true);
}
}<?php
declare(strict_types=1);
use function Cake\Core\h;
/**
* @var \Interact\View\TemplateView $this
* @var string $edition
* @var string $note
* @var string $filter
* @var int $saved
*/
?>
<p class="eyebrow">Persistent layout</p>
<h2>Layout notebook</h2>
<p class="quiet">Edition: <strong><?= (string)h($edition) ?></strong></p>
<p class="card-description">Type a draft and switch pages. This island stays mounted, including edits you have not sent to PHP.</p>
<?= $this->Form->create(null, ['x-interact:submit' => '$server.call("remember")']) ?>
<?= $this->Form->control('note', ['label' => 'Notebook draft', 'value' => $note, 'x-interact:model' => 'note']) ?>
<?= $this->Form->control('filter', ['label' => 'Notebook URL filter', 'value' => $filter, 'x-interact:model.change' => 'filter']) ?>
<div class="button-row">
<?= $this->Form->button('Remember draft', ['class' => 'button-primary']) ?>
<button type="button" @click="$server.call('openDetails')">Open details through an action</button>
</div>
<?= $this->Form->end() ?>
<p class="navigation-notebook-status" role="status">Remembered <strong><?= $saved ?></strong> times</p>
<button class="button-text" type="button" @click="$server.sync()">Refresh notebook</button><?php
declare(strict_types=1);
use function Cake\Core\h;
/**
* @var \Interact\View\TemplateView $this
* @var string $page
*/
$this->assign('title', $page . ' · Navigation · Interact');
$this->append('script', '<meta name="description" content="' . (string)h($page) . ' navigation example">');
$edition = (string)$this->getRequest()->getQuery('edition', 'shared');
$loading = $this->getRequest()->getQuery('manual') === '1' ? 'manual' : 'eager';
?>
<section class="page-intro">
<p class="eyebrow">Enhanced navigation</p>
<h1><?= (string)h($page) ?></h1>
<p class="intro-copy">Move between server-rendered pages. Your layout notebook keeps its draft and its island.</p>
</section>
<div class="navigation-example">
<aside class="demo-card navigation-notebook" x-interact:persist="notebook">
<?= $this->Interact->render('NavigationNotebook', props: ['edition' => $edition], key: 'layout-notebook', loading: $loading) ?>
<p class="field-hint">Scroll these references, then change pages. This layout region keeps its position.</p>
<div class="navigation-scroll" role="region" tabindex="0" aria-label="Scrollable notebook references">
<?php for ($line = 1; $line <= 20; $line++): ?>
<p>Reference <?= $line ?></p>
<?php endfor; ?>
</div>
</aside>
<section class="demo-card navigation-page">
<p class="eyebrow">Page-owned content</p>
<nav class="navigation-links" aria-label="Navigation example">
<a href="/navigation" x-interact:navigate.preserve-scroll>Overview</a>
<a href="/navigation/details" x-interact:navigate.hover.preserve-scroll>Details</a>
</nav>
<h2>A fresh island on every page</h2>
<p class="card-description">
Add five to the counter, then switch between Overview and Details.
The counter resets to zero because it belongs to the page.
Your notebook on the left keeps its draft and remembered count.
</p>
<?= $this->Interact->render('Counter', props: ['label' => 'Page counter'], key: 'page-counter') ?>
<section class="navigation-tools" aria-labelledby="navigation-tools-heading">
<p class="eyebrow">Try another behavior</p>
<h2 id="navigation-tools-heading">Scroll, history, and mount identity</h2>
<dl class="navigation-experiments">
<div>
<dt><a href="/navigation/details#destination" x-interact:navigate>Jump to destination</a></dt>
<dd>Open Details and land at the section below. A fragment on the current page uses normal anchor navigation.</dd>
</div>
<div>
<dt><a href="/navigation/details" x-interact:navigate.preserve-scroll>Keep page scroll</a></dt>
<dd>Scroll down first, then follow this link. The destination keeps your current page position.</dd>
</div>
<div>
<dt><a href="/navigation/details?edition=new" x-interact:navigate>Change notebook props</a></dt>
<dd>Switch the notebook edition to <strong>new</strong>. Changing its server-owned prop mounts a fresh notebook instead of retaining the old one.</dd>
</div>
</dl>
<p class="field-hint">These links demonstrate variations of the Details destination; only the two tabs above represent page selection.</p>
</section>
<section class="navigation-history-note">
<p class="eyebrow">Browser history</p>
<h2>Go back without replaying an action</h2>
<p>Enter a notebook URL filter, change pages, then use Back and Forward.
The URL filter follows history, your unrelated notebook draft survives,
and the page counter starts fresh on each page visit.</p>
</section>
<h2 id="destination">Destination section</h2>
<p>Fragment navigation lands here. Back and Forward restore page scroll using fresh server HTML.</p>
</section>
</div>
<details class="demo-card navigation-lifecycle" x-interact:persist="navigation-lifecycle">
<summary>More lifecycle examples: uploads and polling</summary>
<p class="card-description">Open this panel, select a temporary file or start polling, then switch pages above. These layout islands keep their existing clients.</p>
<div class="navigation-example">
<?= $this->Interact->render('DocumentUpload', key: 'layout-upload') ?>
<?= $this->Interact->render('JobProgress', key: 'layout-job') ?>
</div>
</details><?php
declare(strict_types=1);
namespace TestApp\Controller;
use Cake\Routing\Attribute\Get;
/**
* Demonstrates document navigation through ordinary CakePHP routes and templates.
* The shared layout editor is authorized afresh on each destination mount.
*/
class NavigationController extends AppController
{
/**
* Render the first page with an independently persistent layout editor.
*/
#[Get('/navigation', name: 'demo:navigation')]
public function index(): void
{
$this->set('page', 'Overview');
}
/**
* Render another ordinary action using the same example template.
*/
#[Get('/navigation/details', name: 'demo:navigation-details')]
public function details(): void
{
$this->set('page', 'Details');
$this->viewBuilder()->setTemplate('index');
}
}The example's wrapper is outside its embedded islands. The shared demo layout separately persists its navigation header and renders a delayed indicator using $store.interactNavigation.loading(). No plugin CSS is required.
The layout's Navigation link uses x-interact:navigate.trail to stay highlighted on /navigation/details. The Overview and Details tabs keep exact-page matching. See active trails for matching rules and application-owned styling.
Changing the notebook's edition prop intentionally replaces its client. The Overview and Details tabs preserve page scroll; Details also prefetches on hover or focus. The page counter mounts fresh on every visit. The notebook filter demonstrates URL-state ownership; the notebook draft survives without implicit synchronization or persistence.
See x-interact:navigate for events, head assets, history, scroll, and feedback. See x-interact:persist for compatible mount requirements and authorization boundaries.
The optional lifecycle panel reuses the existing document-upload and polling examples. It demonstrates retained staged files and polling clients without introducing another upload or job API.