Packages

Native Phoenix LiveView UI components — server-rendered SVG charts and more, themeable via CSS variables (Fluxon-compatible). No React, no JS chart libraries.

Current section

Files

Jump to
lantern_ui README.md
Raw

README.md

# LanternUI

Native Phoenix LiveView UI components — server-rendered SVG charts (and more over
time), themeable via CSS variables. **No React, no JavaScript charting library.**

Part of the lantern family: [`lantern`](https://github.com/go9/lantern) is an
embeddable Postgres table viewer; `lantern_ui` is the UI component set.

## What's here

Public component families are grouped below. Each module's generated docs list
the complete function and attribute surface.

- **Forms and input:** `LanternUI.Components.Form`, `LanternUI.Components.Button`,
  `LanternUI.Components.Autocomplete`, `LanternUI.Components.Select`,
  `LanternUI.Components.Checkbox`, `LanternUI.Components.Radio`,
  `LanternUI.Components.Switch`, `LanternUI.Components.Slider`,
  `LanternUI.Components.Textarea`, `LanternUI.Components.ColorInput`,
  `LanternUI.Components.DatePicker`, `LanternUI.Components.DatetimeField`, and
  `LanternUI.Components.Calendar`.
- **Overlays and navigation:** `LanternUI.Components.Modal`,
  `LanternUI.Components.AlertDialog`, `LanternUI.Components.Sheet`,
  `LanternUI.Components.Popover`, `LanternUI.Components.Tooltip`,
  `LanternUI.Components.Dropdown`, `LanternUI.Components.Menu`,
  `LanternUI.Components.Tabs`, `LanternUI.Components.Accordion`,
  `LanternUI.Components.Command`, `LanternUI.Components.Layout`,
  `LanternUI.Components.Breadcrumb`, `LanternUI.Components.Navlist`, and
  `LanternUI.Components.Pagination`.
- **Data and display:** `LanternUI.Components.Table`,
  `LanternUI.Components.DataTable`, `LanternUI.Components.ResourceList`,
  `LanternUI.Components.Stat`, `LanternUI.Components.Badge`,
  `LanternUI.Components.Avatar`, `LanternUI.Components.Icon`,
  `LanternUI.Components.Separator`, `LanternUI.Components.Timeline`,
  `LanternUI.Components.Waterfall`, and `LanternUI.Components.LogView`.
- **Feedback and status:** `LanternUI.Components.Alert`,
  `LanternUI.Components.Toast`, `LanternUI.Components.EmptyState`,
  `LanternUI.Components.Progress`, `LanternUI.Components.Meter`,
  `LanternUI.Components.Loading`, `LanternUI.Components.Skeleton`, and
  `LanternUI.Components.ScrollArea`.
- **Dense-app primitives:** `LanternUI.Components.ListRow`,
  `LanternUI.Components.GroupBand`, `LanternUI.Components.Inspector`,
  `LanternUI.Components.StateGlyph`, and `LanternUI.Components.SidePanel`.
  Icon actions, rings, segmented controls, and property rows fold into
  `Button` (`label`/`kbd`), `Progress` (`shape="ring"`), `Tabs`
  (`tabs_list variant="segmented"`), and `DescriptionList` (`layout="dense"`)
  (see [Dense-app primitives](docs/dense-app.md)).
- **Theming and layout:** `LanternUI.Components.Theme` supplies the theme
  component and tokens used by the component families.
- **Charts:** `LanternUI.Charts` provides area, bar, line, and
  sparkline components; `LanternUI.Charts.Geometry` provides
  pure chart calculation helpers.
- **Chat:** [Chat Kit guide](https://github.com/go9/lantern-ui/blob/main/docs/chat-kit.md) covers
  `LanternUI.Components.Avatar`, `LanternUI.Components.Message`, and
  `LanternUI.Components.MessageScroller` composition.

See the [published HexDocs](https://hexdocs.pm/lantern_ui) for the released API.

Geometry is computed in Elixir, so charts re-render through normal LiveView
assigns. Client JS lives in the committed `priv/static/lantern_ui_hooks.js`
bundle (esbuild; consumers do not need a JS toolchain).

## Agent skills

Reusable guidance for agents working with LanternUI lives in [`skills/`](skills/):

- `lantern-ui-components` — author or extend library components.
- `lantern-migration` — migrate a Phoenix application onto LanternUI.
- `phoenix-page-design` — design consistent LiveView pages with LanternUI.
- `lantern-recipes` — copy these before writing any list/rail/inbox/overview page.

Import these files from this public repository rather than copying private,
project-specific variants.

## Installation

```elixir
def deps do
  [{:lantern_ui, "~> 0.8"}]
end
```

## Usage

```heex
<LanternUI.Charts.area_chart
  id="price-history"
  series={[%{date: "2024-01-01", value: 24.5}, %{date: "2024-02-01", value: 27.1}]}
  value_format={:currency}
  height={250}
/>

<LanternUI.Charts.sparkline id="trend" series={[3, 5, 4, 6, 8, 7, 9]} />

<LanternUI.Charts.bar_chart
  id="sales"
  series={[%{label: "Q1", value: 42}, %{label: "Q2", value: 31}]}
/>

<LanternUI.Charts.line_chart
  id="pod-cpu"
  series={[
    %{label: "web-1", color: "var(--color-primary)",
      points: [{~U[2024-11-20 14:00:00Z], 0.25}, {~U[2024-11-20 14:05:00Z], 0.31}]},
    %{label: "web-2", points: [{~U[2024-11-20 14:00:00Z], 0.18}, {~U[2024-11-20 14:05:00Z], 0.22}]}
  ]}
  value_format={&"#{&1} cores"}
/>
```

`line_chart` `series`: a list of `%{label, color, points: [{datetime, number}]}`
(`color` optional; `points` accept `{datetime, value}` tuples or `%{time, value}`
maps; datetime = `DateTime`/`NaiveDateTime`/`Date`/ISO-8601 string).

`area_chart` `series`: a list of `%{date: iso8601 | Date, value: number}`.

## Summary stats

`stat_card/1` exposes the same compact metric presentation used by the data
table overview. Use it for a single metric, or use the slot-driven
`stat_grid/1` for responsive groups. The grid wraps cards from the same
9.5rem basis, so one, two, or many cards fill wide layouts and reflow at narrow
widths without caller breakpoints.

```heex
<.stat_card label="Open orders" value={42} icon="hero-inbox" />

<.stat_grid aria-label="Order summary">
  <:stat label="Open" value={42} />
  <:stat label="Shipped" href={~p"/orders?status=shipped"}>{128}</:stat>
</.stat_grid>

<.stat_grid aria-label="Store health">
  <:stat label="Products" value={@product_count} />
  <:stat label="Low stock" value={@low_stock_count} subtitle="Needs attention" />
  <:stat label="Orders" value={@order_count} />
  <:stat label="Revenue" value={@formatted_revenue} />
</.stat_grid>
```

`stat_card/1` requires `label` and `value`. Every `stat_grid/1` `:stat` slot
requires `label`; provide its metric through either the `value` attribute or
inner slot content, as shown above. `subtitle`, host heroicon `icon`, `href`,
and `class` are optional. Cards without `href` render as non-interactive
`div`s; cards with `href` render as links. Calculations, formatting, trends,
charts, and navigation state remain caller-owned.

## Skeleton placeholders

`Skeleton.skeleton/1` is decorative and needs no JavaScript or Tailwind. Its
bundled `lui-skeleton` CSS makes the default placeholder full width and one line
high; `class` or `style` can supply application-specific geometry. Mark the
surrounding loading region busy and give that region an accessible name:

```heex
<section aria-busy="true" aria-label="Loading profile">
  <LanternUI.Components.Skeleton.skeleton
    class="profile-avatar-placeholder"
    style="width: 3rem; height: 3rem; border-radius: 9999px;"
  />
  <LanternUI.Components.Skeleton.skeleton style="width: 12rem;" />
  <LanternUI.Components.Skeleton.skeleton style="height: 8rem; margin-top: 1rem;" />
</section>
```

Each skeleton renders with `aria-hidden="true"`. Its pulse automatically stops
when the user enables reduced motion.

### Accordion

`accordion/1` and `accordion_item/1` mirror Fluxon 2.3.1, so an existing call can
migrate by changing only `use Fluxon` to `use LanternUI`:

```heex
<.accordion id="faq" prevent_all_closed animation_duration={300}>
  <.accordion_item id="shipping" expanded>
    <:header>Where do you ship?</:header>
    <:panel>Worldwide.</:panel>
  </.accordion_item>
  <.accordion_item id="returns" icon={false}>
    <:header class="font-semibold">What is the return window?</:header>
    <:panel class="prose">Thirty days.</:panel>
  </.accordion_item>
</.accordion>
```

Both ids are optional and generated when omitted. `multiple` allows several
panels open; `prevent_all_closed` keeps one open. The panel remains in the DOM
and is hidden when collapsed so ARIA relationships stay valid. The indicator
uses `animation_duration`; `prefers-reduced-motion: reduce` disables its
transition.

### Destructive confirmations

Use `alert_dialog/1` instead of hand-building destructive modal semantics. Its
four slots are required; cancel is focused first, backdrop clicks are ignored,
and Escape closes while restoring focus to the trigger.

```heex
<.button phx-click={LanternUI.open_dialog("delete-project")}>Delete…</.button>

<.alert_dialog id="delete-project">
  <:title>Delete this project?</:title>
  <:description>This permanently deletes the project and its data.</:description>
  <:cancel>
    <.button phx-click={LanternUI.close_dialog("delete-project")}>Cancel</.button>
  </:cancel>
  <:action>
    <.button color="danger" phx-click="delete-project">Delete project</.button>
  </:action>
</.alert_dialog>
```

The application owns action and cancel events. Use an alert dialog only for an
important, usually irreversible confirmation, not for informational content.

## JS hooks (mandatory for Accordion and interactive components)

**Register the complete `LanternHooks` bundle whenever Accordion is used.**
Without `LanternAccordion`, headers do not toggle, keyboard navigation does not
run, and `prevent_all_closed` cannot be enforced. Interactive components,
including `alert_dialog` through `LanternModal`, ship their hooks in the same
bundle. `area_chart` uses `ChartHover`, and `line_chart` uses `LineHover`; the
single import below registers all shipped hooks. In `assets/js/app.js`:

```js
import LanternHooks from "../../deps/lantern_ui/priv/static/lantern_ui_hooks.js"

let Hooks = { ...LanternHooks /* , ...yourOtherHooks */ }
let liveSocket = new LiveSocket("/live", Socket, { params: {/* ... */}, hooks: Hooks })
```

The file you import is the esbuild bundle of `assets/js/`. Data-attribute
behaviours (list keyboard nav, persist, collapse) install themselves on import — see
[docs/behaviours.md](docs/behaviours.md). Overlay panels (popover, dropdown,
select, menu, autocomplete) are placed with `@floating-ui/dom`, inlined in
the bundle so the import path stays
`deps/lantern_ui/priv/static/lantern_ui_hooks.js`.

`sparkline` and `bar_chart` need no JavaScript. Accordion always requires the
hook bundle above.

## Autocomplete

`autocomplete` filters local options by default and keeps the selected value in a
normal hidden form input:

```heex
<.autocomplete field={@form[:country]} options={@countries} clearable />
```

For remote data, the LiveView owns the result list. Set `on_search`; the hook
pushes `%{"query" => query}` after `search_threshold` and `debounce`, displays its
loading state, and stops loading when the patched options arrive:

```heex
<.autocomplete
  field={@form[:user_id]}
  options={@user_results}
  on_search="search_users"
  search_threshold={2}
  debounce={250}
  open_on_focus
  clearable
>
  <:option :let={{name, id}}>
    <strong>{name}</strong> <small>#{id}</small>
  </:option>
  <:empty_state>No matching users</:empty_state>
</.autocomplete>
```

Options may be nested labelled groups. A Fluxon-style `{label, children}` is a
group when `children` is a non-empty tuple list. Use
`{:group, label, children}` for an empty group or scalar children; this explicit
form preserves existing `{label, list_value}` options without ambiguity.
`inner_prefix`, `inner_suffix`, `outer_prefix`, `outer_suffix`, `header`, and
`footer` slots customize the surrounding states without moving search or
selection ownership into LanternUI.

The `animation`, `animation_enter`, and `animation_leave` attrs are accepted as
Fluxon compatibility no-ops. Like Lantern's modal, autocomplete motion is
controlled by the bundled CSS and duration tokens.

## Command palette

`command` is a ⌘K dialog: a modal combobox over a listbox of actions. It opens
and closes through the same contract as `modal` — `LanternUI.open_dialog/1` /
`close_dialog/1` (or the socket forms) — plus a global Meta/Ctrl+`hotkey`.

**The component never filters.** It renders exactly the items you give it and
pushes the query upward, so the LiveView can answer from a database, a search
index, or anything else:

```heex
<.command id="cmd-k" on_search="command_search" on_select="command_select">
  <.command_group label="Tickets">
    <.command_item :for={t <- @results} value={t.id}>
      {t.title}
      <:icon><.icon name="inbox" /></:icon>
      <:description>{t.project}</:description>
      <:shortcut>⏎</:shortcut>
    </.command_item>
  </.command_group>
  <.command_empty :if={@results == []}>No matches for “{@query}”</.command_empty>
  <:footer>↑↓ to navigate · ⏎ to select</:footer>
</.command>
```

```elixir
def handle_event("command_search", %{"query" => query}, socket) do
  {:noreply, assign(socket, query: query, results: MyApp.Search.run(query))}
end

def handle_event("command_select", %{"value" => id}, socket) do
  {:noreply, push_navigate(socket, to: ~p"/tickets/#{id}")}
end
```

The `LanternCommand` hook pushes both events itself, so the palette renders **no
`<form>` and no `phx-change` / `phx-submit`**, and its event names default to the
`command_*` namespace. That matters when the palette is mounted in an app shell
on every page: a global component with a generic `phx-change="search"` makes a
host app's own `element("form")` and `form[phx-change="search"]` test selectors
ambiguous. An item that carries its own `phx-click` is left alone — the hook does
not also push `on_select`, so per-item bindings are never double-fired.

Keyboard: `↑`/`↓` move the highlight, `Home`/`End` jump, `Enter` activates,
`Escape` closes. Focus is trapped in the panel while open and restored on close.
Focus stays in the input (this is an APG combobox), so the highlighted item is
published with `aria-activedescendant` rather than by moving DOM focus.

`command_separator`, `command_shortcut`, and a `loading` attr round out the
anatomy. Set `hotkey={nil}` to drop the global shortcut, `on_search={nil}` /
`on_select={nil}` to drop an event, and `close_on_select={false}` to keep the
palette open after a choice.

## Theming

Components read colors from CSS variables with chained fallbacks:

| Purpose | Variable chain |
|---|---|
| accent | `--lantern-accent` → `--color-primary-500` → `#3b82f6` |
| text | `--lantern-fg` → `--foreground` → `#111827` |
| muted text | `--lantern-fg-muted` → `--foreground-softer` → `#6b7280` |
| surface (tooltip) | `--lantern-surface` → `--background-base` → `#ffffff` |

Two stylesheets ship in `priv/static`:

- **`lantern_ui.css` — component styles, always import it** when using any
  `lui-*` component (button, pickers, modal, dropdown, …). Charts don't need it.
- **`lantern_ui_theme.css` — the optional default theme** (tokens, light/dark,
  density modes).

Pick per host:

- **Standalone / public?** Import both:

  ```css
  /* assets/css/app.css */
  @import "../../deps/lantern_ui/priv/static/lantern_ui_theme.css";
  @import "../../deps/lantern_ui/priv/static/lantern_ui.css";
  ```

- **Using Fluxon** (or any system defining design tokens)? Import only the
  component styles and bridge `--lantern-*` onto your tokens (full example in
  the `lantern_ui.css` header). Charts need neither — they inherit through
  their built-in fallbacks.
- **Recolor** by setting any `--lantern-*` variable yourself.

**Compact type + grey roles.** Dense chrome uses `text-meta` (11px),
`text-caption` (12px), and `text-mono-meta` instead of `text-[11px]`. The four
grey roles are `text-foreground`, `text-foreground-soft`,
`text-foreground-softest`, and `text-muted-foreground`.
`text-foreground-softer` is a deprecated alias of `-soft`. Full table and the
consumer lint (`mix lantern.lint`) are in [docs/scale.md](docs/scale.md).

## Value formatting

`area_chart` and `bar_chart` accept `value_format`: `:number` (default),
`:currency` (USD-style `$` prefix), or a 1-arity function `(number -> String.t())`.
Any other value is treated as `:number`. Custom-function output is rendered as
plain text — it is HTML-escaped before it reaches the tooltip.

## Development

```bash
mix test    # Elixir: rendering, ARIA conformance, class merging
mix lantern.lint   # optional: same check consumers run (this repo is mostly lui-* CSS)
npm run build      # assets/js → priv/static/lantern_ui_hooks.js (commit the result)
npm test           # JavaScript: the committed hooks bundle against jsdom
```

`npm test` needs `npm install` once; it runs `node --test test/js/*.mjs`, which
mounts each hook against a jsdom document and drives it the way a user would.
Two `command` bugs — the palette vanishing on the first keystroke, and events
never reaching a LiveComponent — were invisible to the Elixir suite because both
lived entirely in the hook, so interactive behaviour belongs in `test/js/`.

jsdom, esbuild, and `@floating-ui/dom` are dependencies of a private
`package.json` used only to build and test this repo. The Hex package ships the
already-bundled `priv/static/lantern_ui_hooks.js`, so consumers never need a JS
toolchain. The previous “zero JS dependencies / unbundled ESM” goal was retired
on 2026-09-20 — the public import path did not change.

## License

MIT — see [LICENSE](LICENSE).