Skip to content
caramelcookbook · 0.16.0
Browse documentation
Cookbook / Views

Mount a third-party component.

Wrap a headless JavaScript library in a Caramel island. The worked example is a11y-dialog 8.1.5 as a navigation dialog: vendor its file, mount it with props the server renders, clean up after it, survive history navigation and refresh a region beside it.

Draft recipeTarget: 0.16.0Not yet recipe-tested
ContractValue
NeedsNode and npm for the vendoring step, and locales enabled with frappe make locale en, because the view reads its text through t.
You will haveAn a11y-dialog 8.1.5 navigation dialog mounted as an island, with a region beside it that refreshes from a server event
StatusDraft: no check builds this recipe; scripts/check browser proves the island lifecycle with a probe component, not this adapter
Proved byfrappe corretto spec/requests/guide_spec.cr, then nine checks by hand under “Test it”

Where the change goes

app/assets/vendor/a11y-dialog-8.1.5.min.jsVendored library
app/views/layouts/application.crLoads scripts once
app/views/guide/show.crFallback and mount point
app/views/activity/show.crRefreshed region
app/assets/javascript/app.jsIsland adapter
app/assets/stylesheets/app.cssDialog appearance
app/actions/guide/show.crPasses links to the view
app/actions/activity/show.crAnswers region refresh
app/actions/activity/stream.crEvent stream
app/locales/en.crNavigation text keys
config/routes.crCentral routes
spec/requests/guide_spec.crServer-side checks

Vendor the library and load it once

frappe dev and frappe build publish every file under app/assets/ at /assets/: the folders stylesheets, javascript and vendor are dropped from the path, any other folder keeps its subpath, and two files with the same output path fail with Asset output conflict. Copy the library’s minified bundle there under a versioned name, so nothing is fetched from a CDN at run time. The view below reads its text through t., so enable locales first with frappe make locale en.

  1. Vendor the bundle
    Terminal · inside the application
    npm pack a11y-dialog@8.1.5 --ignore-scripts
    tar xzf a11y-dialog-8.1.5.tgz package/dist/a11y-dialog.min.js
    cp package/dist/a11y-dialog.min.js app/assets/vendor/a11y-dialog-8.1.5.min.js
    rm -r package a11y-dialog-8.1.5.tgz

    Expect: The bundle sits at app/assets/vendor/a11y-dialog-8.1.5.min.js. The next frappe dev publishes it as /assets/a11y-dialog-8.1.5.min.js.

  2. Load it before app.js

    The bundle is a UMD file that defines window.A11yDialog in a browser, so the adapter below uses that name. Load it in the shared layout, before app.js: deferred scripts run in order, so the global exists when the adapter file runs.

    app/views/layouts/application.cr · the head
              script src: "/assets/htmx-4.0.0.min.js", defer: true
              script src: "/assets/caramel-islands.js", defer: true
              script src: "/assets/a11y-dialog-8.1.5.min.js", defer: true
              script src: "/assets/app.js", defer: true

The default Content-Security-Policy allows scripts and styles from your own origin only (Security, “Every response carries the same headers”). Keep the dialog’s appearance in app/assets/stylesheets/app.css and toggle classes or the library’s aria-hidden attribute instead of writing style attributes.

ConditionOutcome
A script tag written by one page’s view under boosted navigation, which does not run a new page’s <head> againdoes not load

Write the fallback, then the mount point

The view writes the navigation as plain links, which work with JavaScript off, then the island. Resolve every localized string in the view and pass it as a prop; the browser code never looks up a catalog.

app/views/guide/show.cr
module App::Views::Guide
  class Show < App::ApplicationView
    def initialize(@links : Array(NamedTuple(label: String, href: String)))
    end

    private def blueprint
      h1 { "Guide" }
      nav class: "fallback", aria_label: t.nav.title do
        ul do
          @links.each { |link| li { a(href: link[:href]) { link[:label] } } }
        end
      end
      island "NavigatorDialog", {
        title:  t.nav.title,
        open:   t.nav.open,
        close:  t.nav.close,
        links:  @links,
        region: "activity",
      }
      section id: "activity", "hx-get": "/activity", "hx-trigger": "activity",
        "hx-sync": "this:queue last" do
        render App::Views::Activity::Show.new(Time.utc)
      end
    end
  end
end
config/routes.cr · the central routes
    get "/guide", App::Guide::Show
    get "/activity", App::Activity::Show
    get "/activity/stream", App::Activity::Stream
app/views/activity/show.cr
module App::Views::Activity
  class Show < App::ApplicationView
    def initialize(@at : Time)
    end

    private def blueprint
      h2 { t.nav.activity }
      p { "Updated #{@at.to_s("%H:%M:%S")} UTC" }
    end
  end
end

The island renders its own children: the adapter below replaces them with the opener and the dialog. The library toggles aria-hidden on the dialog container and leaves its appearance to you, so the stylesheet must hide a closed dialog, and a :has rule in app.css hides the fallback nav once the island is mounted. Stylesheet:

app/assets/stylesheets/app.css · appended
.dialog-container {
  position: fixed;
  inset: 0;
  z-index: 10;
  display: flex;
  align-items: center;
  justify-content: center;
}
.dialog-container[aria-hidden="true"] {
  display: none;
}
.dialog-overlay {
  position: fixed;
  inset: 0;
  background: rgb(0 0 0 / 0.4);
}
.dialog-content {
  position: relative;
  z-index: 1;
  padding: 1.5rem;
  background: #faf8f3;
  border-radius: 0.5rem;
}
nav.fallback:has(+ caramel-island[data-island-state="mounted"]) {
  display: none;
}

The island contract is set out in ADR 0005 ↗; the views reference shows the island helper.

ConditionOutcome
The island has mountedthe element gets data-island-state="mounted" and the :has rule hides the fallback links
The script does not load, or never calls definestays pending and keeps the fallback links
A mount throws, or props is not JSONis error, dispatches a bubbling caramel:island-error event and keeps the fallback links
A mount returns {update, unmount} and a morph changes the propscalls update(props)
A mount returns a function or nothing and a morph changes the propsunmounts, then mounts again
The element is removedcalls unmount
Read the views reference →
app/actions/guide/show.cr
module App::Guide
  struct Show < App::ApplicationAction
    contract do
    end

    def handle(contract : Contract)
      links = [
        {label: t.nav.home, href: "/"},
        {label: t.nav.health, href: "/health"},
      ]
      page "Guide", Views::Guide::Show.new(links)
    end
  end
end
app/locales/en.cr · inside Caramel.locale "en", before the # Frappé resource messages line
  nav: {
    title:    "Browse & search",
    open:     "Open the navigation",
    close:    "Close",
    home:     "Home",
    health:   "Health",
    activity: "Recent activity",
  },

Adapt the library to the island lifecycle

Append the adapter to the generated app/assets/javascript/app.js, which already holds a focus handler. The adapter builds the dialog’s DOM itself. It never writes a data-a11y-dialog attribute, so the library’s own start-up scan, which instantiates every element that has one, finds nothing to take over; the adapter calls new A11yDialog(container) exactly once. The links are created by script, so it calls htmx.process on them to keep them boosted.

app/assets/javascript/app.js
CaramelIslands.define('NavigatorDialog', (element, props) => {
  const container = document.createElement('div');
  container.id = 'navigator-dialog';
  container.className = 'dialog-container';
  container.setAttribute('aria-labelledby', 'navigator-dialog-title');
  container.setAttribute('aria-hidden', 'true');

  const overlay = document.createElement('div');
  overlay.className = 'dialog-overlay';
  overlay.setAttribute('data-a11y-dialog-hide', '');

  const content = document.createElement('div');
  content.className = 'dialog-content';
  const title = document.createElement('h2');
  title.id = 'navigator-dialog-title';
  const list = document.createElement('ul');
  const close = document.createElement('button');
  close.type = 'button';
  close.setAttribute('data-a11y-dialog-hide', 'navigator-dialog');
  content.append(title, list, close);
  container.append(overlay, content);

  const opener = document.createElement('button');
  opener.type = 'button';
  opener.setAttribute('data-a11y-dialog-show', 'navigator-dialog');

  element.replaceChildren(opener, container);
  const dialog = new A11yDialog(container);

  function render(next) {
    opener.textContent = next.open;
    title.textContent = next.title;
    close.textContent = next.close;
    list.replaceChildren(...next.links.map((link) => {
      const item = document.createElement('li');
      const anchor = document.createElement('a');
      anchor.href = link.href;
      anchor.textContent = link.label;
      item.append(anchor);
      return item;
    }));
    htmx.process(list);
  }
  render(props);

  const onShortcut = (event) => {
    if (event.altKey && event.code === 'KeyM') dialog.show();
  };
  document.addEventListener('keydown', onShortcut);

  // htmx:after:settle fires on the element that received the swap and bubbles.
  const closeOnSwap = (event) => {
    if (event.target.contains(element)) dialog.hide();
  };
  document.addEventListener('htmx:after:settle', closeOnSwap);

  const region = document.getElementById(props.region);
  const refresh = () => {
    if (region) region.dispatchEvent(new CustomEvent('activity'));
  };
  const source = new EventSource('/activity/stream');
  source.addEventListener('open', refresh);
  source.addEventListener('activity', refresh);

  return {
    update(next) {
      render(next);
    },
    unmount() {
      source.close();
      document.removeEventListener('htmx:after:settle', closeOnSwap);
      document.removeEventListener('keydown', onShortcut);
      dialog.destroy();
      element.replaceChildren();
    },
  };
});

Return {update, unmount}.

CallWhat it does
update(props)Relabels the dialog and rebuilds the links, then processes them again, so the server stays the source of the text
unmount, job 1: destroy()Hides the dialog, removes the library’s document-level click listener and replaces the dialog node with a clone
unmount, job 2: remove other document listenersRemoves each listener the adapter added, here the keyboard shortcut and the swap handler, or each visit leaves another behind
unmount, job 3: source.close()Closes the event stream

Keep it correct across navigation

htmx 4 names its events htmx:phase:action, for example htmx:before:swap and htmx:after:settle; see the htmx events guide ↗. The detail differs by event, so read the version you ship:

EventWhat it carries
htmx:before:init, htmx:after:initThe element lifecycle
htmx:before:history:restoreHistory
htmx:after:swapctx, in the vendored htmx 4.0.0
htmx:after:settleOnly task, newContent and settleTasks; htmx dispatches it on the element that received the swap, and it bubbles to document

A document-wide handler that closes the dialog on a swap must first check that the swap’s target contains the island, or a refresh of the activity region would close it. The adapter’s closeOnSwap does that check. The island itself survives a navigation swap into #content, so this is what closes a dialog after one of its links is followed.

ConditionOutcome
Back or Forward restores the page by swapping the bodyunmounts the island element that leaves and mounts the one that arrives, so no htmx listener is needed for cleanup
A swap whose target contains the island, such as a navigation swap into #contentcalls dialog.hide() through closeOnSwap
A swap into another region, such as the activity region belowleaves an open dialog alone

Refresh a region from a server event

A region beside the island, outside its skip-children subtree, can follow a server event. The adapter opens an EventSource to a stream action and closes it in unmount. On each event it dispatches an activity event on the region; the region fetches itself with hx-trigger="activity" and hx-sync="this:queue last", so a burst of events leaves one request in flight and one queued. A stream action sends events from Caramel::ColdBrew.subscribe through Caramel::SSE.write; see the Cold Brew and actions references, and publish with Caramel::ColdBrew.publish("activity", "changed") from the code that changes the data.

Read the Cold Brew reference →Read the actions reference →
app/actions/activity/stream.cr
module App::Activity
  struct Stream < App::ApplicationAction
    contract do
    end

    def handle(contract : Contract)
      stream "text/event-stream" do |io|
        Caramel::ColdBrew.subscribe("activity") do |updates|
          # Subscribed: now send the headers, so the browser's open event follows.
          io << ": open\n\n"
          io.flush
          loop { Caramel::SSE.write(io, event: "activity", data: updates.receive) }
        end
      end
    end
  end
end
app/actions/activity/show.cr
module App::Activity
  struct Show < App::ApplicationAction
    contract do
    end

    def handle(contract : Contract)
      morph "#activity", with: Views::Activity::Show.new(Time.utc)
    end
  end
end

The server-sent stream drops events while a client is disconnected, and a browser reconnects by itself. The stream action therefore subscribes first, then writes and flushes a comment line before it blocks: a response sends its headers with its first write, so without that frame the browser’s open event would wait for the next real event. The adapter asks the region to fetch on every open, the first one included: that covers events published between rendering the page and subscribing, and a reconnect loads a fresh snapshot instead of trusting the events it missed. The cost is one extra request per mount.

Test it

A Corretto request spec proves what the server owes the browser: the mount point, its escaped props and the fallback links.

spec/requests/guide_spec.cr
require "../spec_helper"

describe "Guide" do
  it "writes the island, its escaped props and the fallback links" do
    Corretto.session do |client|
      page = client.get("/guide")
      page.should have_status(200)
      page.body.should contain(%(<caramel-island component="NavigatorDialog"))
      page.body.should contain("Browse &amp; search")
      page.body.should contain(%(<a href="/health">Health</a>))
      page.body.should contain(%(<a href="/">Home</a>))
    end
  end

  it "answers the region's refresh with a partial for #activity" do
    Corretto.session do |client|
      client.get("/activity").body.should contain("Recent activity")
    end
  end
end

Library behaviour needs a browser. Before you ship, check each of these by hand:

  • Focus moves into the dialog on open and returns to the opener on close.
  • Tab and Shift-Tab wrap inside the dialog.
  • Escape closes the dialog.
  • If the dialog holds a native <select>, its open picker closes alone on the first Escape and the dialog stays.
  • Repeated visits to the page keep exactly one dialog.
  • Back and Forward with the dialog open leave one working dialog.
  • Removing the island through a morph unmounts it and closes the event stream.
  • No caramel:island-error event and no console error appears.
  • The fallback links work with JavaScript off.
The library manages focus, aria-hidden and the Escape key; the adapter removes what it created

Let the library manage focus, aria-hidden and the Escape key; do not reimplement them. Everything the adapter creates or listens to, the adapter removes in unmount.

Prove it

Prove the island

Terminal · inside the application
frappe corretto spec/requests/guide_spec.cr

Passing: Both examples pass: the page writes the island, its escaped props and the fallback links, and the region’s refresh answers with the activity partial. Check the nine browser points above by hand; a request spec cannot show library behaviour.

Draft Written for 0.15.0, not yet run end to end.

No check builds this recipe: its npm pack step needs the network, and its dialog needs a browser. scripts/check browser proves the island lifecycle, pending state included, with a probe component rather than this adapter. The request spec and the nine browser checks above have not been run by a check. The recipe keeps its Draft label until a check runs it from a new application to a passing spec.