LiveView Continuity

LiveView Continuity is an experimental collection of unstyled, accessible interaction primitives for Phoenix LiveView.

Contract-backed, patch-safe interaction primitives for Phoenix LiveView. Components are verified with Live Interaction Contracts and application-specific real-browser conformance tests.

This independent community project explores a narrow ownership model: LiveView keeps authority over application data and rendered content while the browser keeps short-lived interaction state. It originated as an experiment in making that boundary explicit and executable, rather than as a general component library.

Installation

Add LiveView Continuity to your dependencies:

{:liveview_continuity, "~> 0.9.0"}

The package requires Elixir 1.18 or newer, Phoenix 1.8 or newer, and Phoenix LiveView 1.1 or newer. Phoenix 1.8 is required by colocated hooks. Add the LiveView compiler to the consuming project:

compilers: [:phoenix_live_view] ++ Mix.compilers()

After mix compile, import the extracted colocated hook in the application's JavaScript bundle:

import {hooks as continuityHooks} from "phoenix-colocated/liveview_continuity";
const liveSocket = new LiveSocket("/live", Socket, {
hooks: {...continuityHooks}
});

Menu

<.menu id="account-actions" on_action="account_action" class="account-menu">
<:trigger class="account-menu-trigger">Actions</:trigger>
<:item id="edit" class="account-menu-item">Edit</:item>
<:item id="settings" navigate={~p"/settings"}>Settings</:item>
<:item id="archive" typeahead_text="Archive">Archive account</:item>
<:item id="delete" disabled={@cannot_delete}>Delete</:item>
</.menu>

Handle the single server event in the containing LiveView:

def handle_event("account_action", %{"id" => id}, socket) do
# authorize and perform the named action
{:noreply, socket}
end

id is required and stable. Item IDs are logical IDs and become stable DOM IDs scoped by the menu. An item with navigate is a native Phoenix LiveView navigation link and does not invoke on_action; other items are action buttons. typeahead_text is optional; otherwise visible text content is used. close_on_action={false} is a narrow escape hatch for an action whose acknowledged patch must remain inside the open menu. Normal actions close by default.

Ownership

OwnerState
LiveViewitem content, order, disabled state, action outcome
Native popovertop-layer open state and light dismiss
Menu hookopen reflection, actual focus, active item, roving tabindex, typeahead buffer, dismissal reason, focus restoration

Client-owned reflections use stable data-lvc-* styling hooks. Only trigger aria-expanded, data-lvc-open, data-lvc-dismiss-reason, data-lvc-active, and item tabindex are protected with LiveView's narrow JS.ignore_attributes/1 mechanism. Descendants remain server-patchable.

Supported behavior

See MENU.md for the observable contract.

Tabs

<.tabs id="account-sections" value={@selected} on_select="select_section" label="Account sections">
<:tab id="profile" label="Profile">Profile content</:tab>
<:tab id="security" label="Security" disabled={@security_unavailable}>Security content</:tab>
</.tabs>

Tabs are horizontal and manually activated. Left, Right, Home, and End move focus among enabled tabs without selecting. Enter, Space, or click sends exactly one %{"id" => logical_id} event. The server owns selection and all panels remain mounted; inactive panels are hidden and inert. The hook preserves the logical focus cursor through patches and reorder without stealing focus from outside the tab list. See TABS.md for the full contract.

Large LiveViews may render <.tab_list> and matching <.tab_panel> components separately, so adopting the interaction contract does not require relocating existing panel content. The same stable root and logical IDs preserve the reciprocal ARIA graph.

Dialog

<.dialog id="delete-dialog" open={@dialog_open} on_open="open_dialog" on_close="close_dialog" initial_focus="#account-name">
<:trigger>Delete account</:trigger>
<:title>Delete account?</:title>
<:description>This action cannot be undone.</:description>
<input id="account-name" aria-label="Account name" />
<:close>Cancel</:close>
</.dialog>

Dialog uses only native showModal(): the server owns desired open intent while the browser owns effective top-layer state and focus. Trigger and Close requests happen immediately and must be acknowledged by setting open to true or false in on_open and on_close respectively. Escape reports reason escape; explicit Close reports close. See DIALOG.md for focus fallback, patch continuity, scroll locking, and deferred features.

For confirmation flows opened by another component, omit both the trigger and on_open. In this controlled mode, a server open: false to true transition opens the native modal without emitting an open event; close behavior remains unchanged.

Tooltip

<.tooltip id="save-help" delay={600} describedby="save-requirements">
<:trigger>Save</:trigger>
Saves the current draft
</.tooltip>

An icon-only trigger may also perform a LiveView action through the narrow trigger_attrs seam:

<.tooltip
id={"remove-tag-#{tag.id}"}
trigger_attrs={%{
"aria-label" => "Remove #{tag.name}",
"phx-click" => "remove_tag",
"phx-value-tag-id" => tag.id
}}
>
<:trigger>×</:trigger>
Remove {tag.name}
</.tooltip>

Tooltip renders an always-mounted, non-interactive native manual popover. Mouse hover opens after delay; keyboard or programmatic focus opens immediately. Pointer and focus sources combine independently, while Escape or a trigger press dismisses without moving focus. LiveView owns content, delay, disabled state, existence, and optional base description tokens; the browser owns effective open state, timer safety, native Popover state, and the tooltip token in aria-describedby. Consumers own CSS, positioning, and pointer-events: none. See TOOLTIP.md for patch behavior, accessibility limits, and deferred scope.

For cross-browser positioning and Dialog first-paint guidance, see Application integration.

Non-scope

The 0.1 Menu intentionally excludes selection, check/radio menu items, submenus, portals, detached or multiple triggers, and a positioning engine. The package also excludes a generic state machine, arbitrary JavaScript assertion API, component generator, CSS framework, and design tokens.

Accordion

<.accordion id="faq" values={@expanded} on_change="accordion_change">
<:item id="shipping" label="Shipping">Shipping details</:item>
<:item id="returns" label="Returns">Return details</:item>
</.accordion>

LiveView owns items and authoritative values; the hook reflects each native button/panel immediately and preserves the latest unacknowledged desired set through stale patches. The event payload is %{"id" => id, "open" => boolean, "values" => values} and the handler acknowledges it by assigning values. Panels remain mounted, headings use native buttons, disabled triggers remain focusable and inert, and region landmarks are opt-in. See ACCORDION.md.

Disclosure

<.disclosure id="account-details" default_expanded={false}>
<:trigger>Account details</:trigger>
Content remains mounted and server-patchable.
</.disclosure>

Disclosure is a standalone browser-owned boolean: default_expanded seeds only SSR or a newly mounted instance, while a retained root preserves effective open state across LiveView patches. The native trigger and panel keep aria-expanded, hidden, and aria-hidden synchronized without sending a server event. Use root data-lvc-open for indicator styling. See DISCLOSURE.md.

Radio Group

<.radio_group> renders native radios and keeps browser selection optimistic through stale LiveView patches while the server remains authoritative. Native Space, validation, FormData, and reset semantics are preserved; a local Arrow handler only normalizes cross-engine wrap behavior, and read_only is the narrow interception required because HTML radios have no readonly attribute. See RADIO_GROUP.md.

Options accept either the concise label attribute or structured inner label content:

<.radio_group id="status" name="status" value={@status} on_change="filter" label="Status">
<:option value="all" label="All" />
<:option value="draft">
<span>Draft</span><span class="count">{@draft_count}</span>
</:option>
</.radio_group>

Switch

<.switch> renders a native checkbox with switch semantics, keeps browser interaction optimistic through stale LiveView patches, and sends exact desired state rather than an ambiguous toggle command. Its string label form is concise, while inner content supports structured labels. See SWITCH.md.

<.switch
id="ticket-sale"
name="ticket_sale"
value="enabled"
checked={@sale_active}
on_change="set_ticket_sale"
>
<span>Ticket sales</span>
<span class="state">{if @sale_active, do: "Open", else: "Paused"}</span>
<:description>Controls whether attendees can purchase tickets.</:description>
</.switch>

The containing LiveView acknowledges the exact boolean:

def handle_event("set_ticket_sale", %{"checked" => checked}, socket) when is_boolean(checked) do
{:noreply, assign(socket, :sale_active, checked)}
end

Checkbox

<.checkbox> provides native checkbox semantics (without role="switch") with the same server-authoritative, optimistic, stale-patch-safe checked-state contract. Native FormData, required/disabled behavior, cancelable reset, external form ownership, synthetic read-only, structured labels, and description/error composition are preserved. Events are owner-targeted, so a checkbox nested in a LiveComponent sends exactly %{"checked" => boolean} to that component. See CHECKBOX.md.

<.checkbox id="terms" name="terms" value="accepted" checked={@accepted} on_change="set_terms" required>
<span>Accept terms</span>
<:description>Required to continue.</:description>
</.checkbox>

Popover

<.popover id="date-picker" class="picker" trigger_class="picker-trigger" popup_class="picker-panel">
<:trigger>Choose a date</:trigger>
<.calendar value={@date} />
<button phx-click="clear_date" data-lvc-popover-close>Clear</button>
</.popover>

Popover is an always-mounted native popover="auto" surface. LiveView owns and patches its interactive body; the browser owns transient open state and native dismissal behavior. Retained patches preserve trigger/popup identity, open state, and a retained focused descendant. A popup-body data-lvc-popover-close descendant closes immediately without suppressing its own phx-click; the hook repairs focus only when Escape or an explicit close strands it in the closed popup. Root data-lvc-open and literal trigger aria-expanded reflect effective state. Consumers remain responsible for the body's semantic role, accessible name, and keyboard model. See POPOVER.md.

Popover depends on the native HTML Popover API and ships no fallback or polyfill. It intentionally has no positioning engine, controlled or modal mode, hover behavior, callbacks, portals, animation, arbitrary trigger attributes, or shared overlay abstraction.

Verification

Install and build:

mix deps.get
npm install
mix compile
NODE_PATH="$PWD/deps:$PWD/_build/dev" npx esbuild fixture/assets/js/app.js \
--bundle --target=es2022 --outfile=fixture/priv/static/assets/app.js
mix run fixture/server.exs

In another shell, run the app-specific three-engine gate and the published LIC 1.3 runner:

npm run test:browser
mix live_interaction_contracts.check \
--url http://127.0.0.1:4140 \
--contract test/interaction_contracts/menu_patch.json
# Run again with test/interaction_contracts/tabs_patch.json, dialog_patch.json, tooltip_patch.json, accordion_patch.json, disclosure_patch.json, popover_patch.json, radio_group_patch.json, switch_patch.json, and checkbox_patch.json

Also run mix format --check-formatted, mix compile --warnings-as-errors, mix test, node --check playwright/conformance.mjs, test/package-smoke.sh, mix hex.build, and mix docs. The fixture uses loopback port 4140 and the shipped component plus its compiler-extracted colocated hook. The package smoke unpacks the Hex artifact into a fresh consumer, compiles it, and bundles the extracted hook through esbuild.

The LIC v1 contracts honestly check native surface state, node identity, focus, and owned ARIA attributes around acknowledged patches. Tooltip's LIC case proves retained popup identity plus source-exit close and ARIA cleanup; its focused-open continuity is verified by the three-engine application-specific Playwright driver. Rich interaction semantics stay in that driver because LIC 1.3 has no arbitrary assertion surface. Negative controls that prove verifier sensitivity belong to the independently released Live Interaction Contracts project; this package owns positive conformance for its shipped components.

Inspiration and status

Behavioral rigor and composition are inspired by React Aria and Base UI. LiveView Continuity is not API-compatible with either project, and it does not copy a React lifecycle model. This is an independent community project, not affiliated with Phoenix, React Aria, Base UI, or the Live Interaction Contracts project.

Released under the MIT License.