
Keep Phoenix in charge. Let JavaScript do what it does best.
Xenia is the small, simple adapter between the two.
Xenia brings JavaScript libraries (charts, code editors, maps, terminals) into Phoenix LiveView, and lets your folders define your pages.
Contents
- What you get
- Why Xenia
- How it works
- Start a new app
- Quickstart
- Writing an adapter
- Official adapters
- More features: forms, web components, files, app-wide values, keyboard shortcuts, loading and errors, adapters next to the page
- Pages from folders
- Generators
- Configuration and troubleshooting
- Examples and guides
- Developing Xenia
- Contact · License
What you get
JavaScript in LiveView
- Any npm library with one small adapter: how it starts, updates and stops.
- 16 official adapters: Monaco, Tiptap, Chart.js, D3, Tabulator, FullCalendar, Leaflet, xterm.js, flatpickr, Tom Select, Uppy, wavesurfer.js and more.
- Props from your assigns, events to
handle_event, replies for search boxes, and commands from the server to one component. - Form fields, app-wide values (theme, locale), keyboard shortcuts, and loading and error states, without adapter code.
Pages from folders
- File-based routing:
page.exmakes a route,[id]is a parameter. - Nested layouts, parts that stay alive between pages, and pages that share one LiveView.
- Page titles, social-card tags and a sitemap.
Everyday work
mix xenia.installsets up a new Phoenix app; generators write pages, layouts and adapters.- A runnable examples app, and four guides. MIT licensed.
Why Xenia
Xenia (ξενία) is the ancient Greek bond between a host and a guest. Phoenix is the host: it renders the pages, owns the state and stays in charge. A JavaScript library is the guest: it gets a place of its own on the page and does its work there. Xenia is the agreement between them, written once.
The JavaScript community has an excellent library for almost anything a browser can do. With Xenia you use them without hand-writing a LiveView hook for each one: Monaco took a 58-line hook by hand, and is 21 lines as an adapter.
How it works
your LiveView (Elixir) Xenia your adapter (JavaScript)
<.chart data={@sales} ──► sends changed props ──► props: { data(chart, data) }
on_click="clicked" />
def handle_event("clicked") ◄── delivers the event ◄── ctx.emit("click", payload)
Xenia.push_to(socket, ──► routes it to one ──► commands: { highlight(chart, args) }
"sales", "highlight") componentXenia also handles cleanup on navigation, libraries that load slowly, and events sent just before the user leaves a page.
Start a new app
mix phx.new my_app && cd my_app
# add {:xenia, "~> 0.1.0"} to the deps in mix.exs
mix deps.get
mix xenia.install
mix phx.server
mix xenia.install adds a light and dark theme, a layout and home page in the
app folder, Xenia's hooks, and flash messages that stack and close by
themselves. It prints each change, and leaves alone any file you've already
changed.
Quickstart
A Chart.js chart in an existing Phoenix 1.8 app.
1. Add Xenia to mix.exs ({:xenia, "~> 0.1.0"}), run mix deps.get, and
install the library: npm install chart.js --prefix assets.
2. Write the adapter, assets/js/chart_adapter.js:
import Chart from "chart.js/auto"
export const chart = {
mount(el, props, ctx) {
const canvas = el.appendChild(document.createElement("canvas"))
return new Chart(canvas, {
type: props.type,
data: props.data,
options: {
onClick: (_e, points, chart) =>
points[0] && ctx.emit("click", { label: chart.data.labels[points[0].index] }),
},
})
},
destroy(chart) { chart.destroy() },
props: {
data(chart, data) { chart.data = data; chart.update() },
type(_chart, _type, ctx) { ctx.remount() }, // Chart.js can't change type in place
},
events: { click: {} },
}3. Register it in assets/js/app.js:
import {createHooks} from "xenia"
import {chart} from "./chart_adapter"
// in the LiveSocket options:
hooks: {...colocatedHooks, ...createHooks({Chart: chart})},4. Declare the tag, lib/my_app_web/components/charts.ex, then add
import MyAppWeb.Charts to html_helpers in lib/my_app_web.ex, and :xenia
to import_deps in .formatter.exs:
defmodule MyAppWeb.Charts do
use Xenia.Components
component :chart, adapter: "Chart" do
prop :type, :string, required: true
prop :data, :map, required: true
event :click
end
end5. Use it in a LiveView (live "/sales", SalesLive in your router):
def mount(_params, _session, socket), do: {:ok, assign(socket, sales: sales(), clicked: nil)}
def render(assigns) do
~H"""
<.chart id="sales" type="bar" data={@sales} on_click="bar_clicked" style="height: 300px" />
<p :if={@clicked}>You clicked {@clicked}</p>
"""
end
def handle_event("bar_clicked", %{"label" => label}, socket),
do: {:noreply, assign(socket, clicked: label)}
defp sales, do: %{labels: ~w(Jan Feb Mar), datasets: [%{label: "Sales", data: [12, 19, 7]}]}Clicking a bar reaches handle_event, a new @sales updates the same chart,
and HEEx checks the tag's attributes at compile time. Any npm library works the
same way.
Writing an adapter
{
mount(el, props, ctx) // create the library in `el`; return it (or a Promise)
destroy(instance, ctx) // clean up, synchronously
props: { name(instance, value, ctx) {} }, // apply one changed prop
events: { name: { debounce: ms } }, // events it can send
commands: { name(instance, args, ctx) {} }, // things the server can ask it to do
consumes: ["theme"], // app-wide values, received as props
}Only mount and destroy are required. A prop's handler runs only when its
value changed; a prop without one is assigned to the instance. el is yours;
the container around it belongs to LiveView.
ctx | |
|---|---|
emit(name, payload) | send an event; the page names its LiveView event (on_<name>) |
call(name, payload) | send an event, get a Promise of the server's {:reply, map, socket} |
remount() | rebuild the instance, for settings a library can't change in place |
isAlive() | false once destroyed; check it after your own async work |
setFormValue(value) | write the component's form value (forms) |
upload(name, files) | send files through a LiveView upload (files) |
id, hook | the component's id; the raw LiveView hook |
Commands reach one component: Xenia.push_to(socket, "sales", "highlight", %{index: 2}).
Without a declared tag, render the container yourself:
<.xenia id="sales" adapter="Chart" props={%{data: @sales}} events={%{click: "bar_clicked"}} />.
The full contract is in What Xenia owns, what your adapter owns.
Official adapters
Install the library in your app and pass it in. Xenia never adds a JavaScript dependency you don't use.
| Library | Import from | Tag |
|---|---|---|
| Monaco, code and diff editors | xenia/monaco, xenia/monaco-diff | <.monaco>, <.monaco_diff> |
| Tiptap, rich text | xenia/tiptap | <.tiptap> |
| Chart.js, D3 | xenia/chartjs, xenia/d3 | <.chartjs>, <.d3_chart> |
| Tabulator, data tables | xenia/tabulator | <.tabulator> |
| FullCalendar | xenia/fullcalendar | <.full_calendar> |
| Leaflet, maps | xenia/leaflet | <.leaflet_map> |
| xterm.js, terminals | xenia/xterm | <.xterm> |
| flatpickr, Tom Select, Shoelace rating | xenia/flatpickr, xenia/tom-select, xenia/shoelace-rating | <.flatpickr>, <.tom_select>, <.shoelace_rating> |
| SortableJS, drag and drop | xenia/sortablejs | <.sortable_list> |
| Uppy, file uploads | xenia/uppy | <.uppy> |
| wavesurfer.js, audio | xenia/wavesurfer, xenia/wavesurfer-multitrack | <.wavesurfer>, <.wavesurfer_multitrack> |
They all work the same way:
// assets/js/app.js
import {createChartjs} from "xenia/chartjs"
const chartjs = createChartjs({load: () => import("chart.js/auto")})
// hooks: {...colocatedHooks, ...createHooks({ChartJS: chartjs})}<.chartjs id="sales" type="bar" data={@sales} on_point_click="clicked" />Configure one with options (createChartjs({load, plugins, options})), and
extend it by spreading: {...chartjs, commands: {...chartjs.commands, mine() {}}}.
Each tag module (Xenia.Adapters.Tiptap, ...) lists its npm packages, load
line, CSS, props, events and commands. Monaco needs a few build steps for its
workers: see Xenia.Adapters.Monaco.
More features
Forms
A component with field={@form[:code]} is a form field: submitted with the
form, part of phx-change, restored after a reconnect, with errors like
<.input>. The official Monaco, Tiptap, flatpickr, Tom Select and Shoelace
rating adapters handle it; your own adapter calls ctx.setFormValue(value).
Web components
A custom element needs no prop handlers: each prop is set as a property.
Files
ctx.upload("recording", [file]) sends files a component produced through a
LiveView upload (allow_upload and <.live_file_input> on the page).
App-wide values
Publish a value once, and every adapter with consumes: ["theme"] gets it as a
prop: from the browser with setProvider("theme", value), or from the server
with <Xenia.Provider.provider name="locale" value={@locale} />.
Keyboard shortcuts
shortcuts={%{"mod+s" => "save"}} sends a LiveView event while the component
has focus. mod is Cmd on a Mac and Ctrl elsewhere; browser shortcuts are
refused.
Loading and errors
<.monaco id="editor" value={@code} on_crash="editor_crashed">
<:loading>Loading the editor…</:loading>
<:error>It broke. <button type="button" data-xenia-retry>Try again</button></:error>
</.monaco>Adapters next to the page
An adapter only one page uses can live in that page's module, in a LiveView
colocated hook (adapter={colocated(".Chart")}), with no change to app.js.
See Wrapping a library.
Pages from folders
Optional: your folders define your routes, like the Next.js App Router.
# lib/my_app_web/router.ex
import Xenia.Routes
# inside your browser scope:
live_session :app do
xenia_routes "lib/my_app_web/app"
endlib/my_app_web/app/
layout.ex frames every page
page.ex /
posts/[slug]/page.ex /posts/:slug
(auth)/layout.ex a sign-in check for the pages inside
(auth)/persistent.ex a sidebar that stays alive between them
(auth)/dashboard/page.ex /dashboard
settings/live.ex one LiveView for the pages below
_components/card.ex never a routepage.exis a LiveView withuse Xenia.Page; its folder is its URL.[slug]is a parameter,[...path]the rest of the URL,(group)stays out of the URL,_privateis never a route.layout.exframes the pages below it; itson_mount:protects them.persistent.exkeeps its state while users move between those pages.metadata/1sets titles and social tags;Xenia.Sitemapserves a sitemap.
It compiles to ordinary live routes: mix phx.routes and ~p work as usual.
The guide: Building pages from folders.
Generators
mix xenia.gen.component chart data:map --events click # an adapter and its tag
mix xenia.gen.page reports/sales # a page.ex
mix xenia.gen.layout "(auth)" --persistent # a layout.ex and persistent.exConfiguration and troubleshooting
- Custom binding prefix: if your
LiveSocketuses one, set the same:config :xenia, binding_prefix: "pop-". - "Could not resolve "xenia"": run
mix deps.get. For a path dependency, add the folder that contains thexeniacheckout toNODE_PATHin your esbuild config. - Changes to a path dependency don't show up:
mix deps.clean xenia --build, then restartmix phx.server.
Examples and guides
- Examples: a runnable Phoenix app, one page per feature, each with a short README.
- Wrapping any JavaScript library
- What Xenia owns, what your adapter owns
- Building pages from folders
- Icons, notifications and languages
Developing Xenia
lib/xenia/core/ the JS-island boundary: <.xenia>, the DSL, providers, shortcuts
lib/xenia/app/ pages from folders: routes, pages, layouts, metadata, sitemap
lib/xenia/adapters/ the official adapters' tags
lib/mix/ mix xenia.install, xenia.routes and the generators
assets/ the JavaScript: core/ and adapters/, prebuilt into priv/static
test/ the same layout, plus test/js
examples/ the examples appmix setup # dependencies, esbuild and the bundles
mix check # format, compile, bundles, tests, the Hex package and the docs
Commit the rebuilt priv/static with your change; CI checks it matches the
sources.
Contact
Bugs, questions and ideas: GitHub issues. Anything else, including security problems you'd rather not post publicly: uzaydurdu.com.
License
MIT. Free to use in open-source and commercial projects.