Current section

Files

Jump to
phoenix_paper README.md
Raw

README.md

<p align="center">
  <img src="priv/static/images/logo/phoenixpaper-lockup-card.svg" alt="PhoenixPaper" width="320">
</p>

# PhoenixPaper

[![Hex.pm](https://img.shields.io/hexpm/v/phoenix_paper.svg)](https://hex.pm/packages/phoenix_paper)
[![Hexdocs](https://img.shields.io/badge/hex-docs-blue.svg)](https://hexdocs.pm/phoenix_paper)
[![License](https://img.shields.io/hexpm/l/phoenix_paper.svg)](https://github.com/z7ealth/phoenix_paper/blob/master/LICENSE)

A Material Design component library for Phoenix, in the spirit of
[ember-paper](https://github.com/miguelcobain/ember-paper), styled with
Tailwind CSS. Most individual components follow the API and behavior of
[MUI](https://mui.com/material-ui/) (Material-UI for React), adapted to
Phoenix's server-rendered, stateless-function-component model.

See [`AGENTS.md`](AGENTS.md) for the framework's ground rules: the
`paperize` escape hatch every component supports, theming, elevation/spacing
helpers, and the icon strategy (reusing the heroicons every `mix phx.new`
app already vendors, no extra dependency).

## Status

> [!WARNING]
> PhoenixPaper is in active development. Bugs are expected, and component
> APIs may change between `0.x` releases (breaking changes are always
> called out in the [CHANGELOG](CHANGELOG.md)). The goal is a stable,
> semver-guaranteed API at **1.0.0**. Until then, pin a minor version
> (e.g. `~> 0.3.0`) and please
> [report issues](https://github.com/z7ealth/phoenix_paper/issues) you run into.

## Installation

Add `phoenix_paper` to your `mix.exs` deps:

```elixir
def deps do
  [
    {:phoenix_paper, "~> 0.3.0"}
  ]
end
```

Then, in `lib/my_app_web.ex`, import the components next to your existing
`core_components`:

```elixir
defp html_helpers do
  quote do
    use PhoenixPaper.Components
    # ...
  end
end
```

And wire up the Tailwind theme in `assets/css/app.css`:

```css
@import "tailwindcss";
@import "../../deps/phoenix_paper/priv/static/phoenix_paper.css";
```

The stylesheet carries its own `@source` for PhoenixPaper's `lib/`, so there's no separate `@source` line to add.

## Usage

```heex
<.pp_app_bar position="sticky">
  <:leading><.pp_drawer_toggle for="app-drawer" /></:leading>
  My App
  <:actions>
    <%!-- System / Light / Dark; System (follow the OS) is the default --%>
    <.pp_theme_toggle />
    <%!-- color="inherit" stays visible on the colored bar --%>
    <.pp_button variant="icon" color="inherit" aria-label="Notifications">
      <.pp_icon name="hero-bell" />
    </.pp_button>
  </:actions>
</.pp_app_bar>

<.pp_drawer id="app-drawer">
  <:header>My App</:header>
  <.pp_list dense>
    <.pp_list_subheader>Main</.pp_list_subheader>
    <.pp_list_item href="/" active={@current_path == "/"}>
      <:leading><.pp_icon name="hero-home" /></:leading>
      Home
    </.pp_list_item>
    <%!-- a collapsible section with a nested list --%>
    <.pp_list_group id="nav-reports" default_open>
      <:leading><.pp_icon name="hero-chart-bar" /></:leading>
      <:label>Reports</:label>
      <.pp_list_item href="/reports/sales">Sales</.pp_list_item>
      <.pp_list_item href="/reports/traffic">Traffic</.pp_list_item>
    </.pp_list_group>
    <.pp_divider />
    <.pp_list_subheader>Account</.pp_list_subheader>
    <.pp_list_item href="/settings" active={@current_path == "/settings"}>
      <:leading><.pp_icon name="hero-cog-6-tooth" /></:leading>
      Settings
    </.pp_list_item>
  </.pp_list>
</.pp_drawer>

<.pp_container max_width="lg">
  <.pp_grid>
    <.pp_grid_item span={12} md={4}>Sidebar</.pp_grid_item>
    <.pp_grid_item span={12} md={8}>Content</.pp_grid_item>
  </.pp_grid>
</.pp_container>

<.pp_stack direction="row" spacing={:sm}>
  <.pp_button>Save</.pp_button>
  <.pp_button variant="outlined">Cancel</.pp_button>
</.pp_stack>

<.pp_image_list cols={3}>
  <.pp_image_list_item src="/images/1.jpg" title="Breakfast" />
  <.pp_image_list_item src="/images/2.jpg" title="Burger" subtitle="Restaurant" />
</.pp_image_list>

<.pp_button color="primary">Save</.pp_button>
<.pp_button color="primary" ripple={false}>No ripple</.pp_button>
<.pp_button href={~p"/issues"} variant="text">Issues</.pp_button>
<%!-- href/navigate/patch render an <a>, so a "button" that navigates
      never nests <button> inside <a> --%>

<.pp_card>
  <:title>Account</:title>
  You have no pending invoices.
  <:actions>
    <.pp_button variant="text">Dismiss</.pp_button>
  </:actions>
</.pp_card>

<%!-- the whole title + body is a link (MUI's CardActionArea) --%>
<.pp_card navigate={~p"/invoices"}>
  <:title>Invoices</:title>
  3 paid this month.
</.pp_card>

<.pp_typography variant="overline" color="primary">New</.pp_typography>
<.pp_typography variant="h5">Release notes</.pp_typography>

<.pp_avatar src="/images/1.jpg" alt="Remy Sharp" />
<.pp_avatar color="primary">OP</.pp_avatar>

<.pp_badge content={4}>
  <.pp_icon name="hero-bell" />
</.pp_badge>

<.pp_chip>Basic</.pp_chip>
<.pp_chip deletable on_delete={JS.push("remove_tag")}>React</.pp_chip>

<.pp_tooltip title="Delete" color="error" variant="outlined" arrow>
  <.pp_button variant="icon"><.pp_icon name="hero-trash" /></.pp_button>
</.pp_tooltip>

<%!-- client-side toggles: no handle_event needed --%>
<.pp_toggle_button toggle pressed>Bold</.pp_toggle_button>
<.pp_button_group>
  <.pp_toggle_button toggle_group="view" pressed>List</.pp_toggle_button>
  <.pp_toggle_button toggle_group="view">Grid</.pp_toggle_button>
</.pp_button_group>

<.pp_collapse id="advanced">
  <:trigger>Advanced options</:trigger>
  <.pp_input name="timeout" label="Timeout" />
</.pp_collapse>

<%!-- A trigger that opens a small anchored popover of actions --%>
<.pp_menu id="profile-menu">
  <:trigger><.pp_icon name="hero-ellipsis-vertical" /></:trigger>
  <.pp_list>
    <.pp_list_item navigate={~p"/profile"}>Profile</.pp_list_item>
    <.pp_list_item phx-click="log_out">Log out</.pp_list_item>
  </.pp_list>
</.pp_menu>

<.pp_paper elevation={2} class="p-4">A raised surface (Card is built on this).</.pp_paper>
<.pp_typography variant="h4">Account settings</.pp_typography>
<.pp_typography variant="caption">Last updated 2 minutes ago</.pp_typography>

<%!-- pp_form = Phoenix's <.form> plus field spacing and an actions row --%>
<.pp_form for={@form} phx-change="validate" phx-submit="save">
  <.pp_input field={@form[:email]} label="Email" />
  <.pp_select field={@form[:country]} label="Country" options={["Canada", "Mexico"]} />
  <.pp_input field={@form[:starts_at]} type="datetime-local" label="Starts at" />
  <:actions>
    <.pp_button type="submit">Save</.pp_button>
  </:actions>
</.pp_form>

<%!-- page numbers as patch links (or on_change="paginate" for events) --%>
<.pp_pagination page={@page} count={@total_pages} path={&~p"/users?page=#{&1}"} />
<%!-- a table footer: rows per page, "11–20 of 47", prev/next --%>
<.pp_table_pagination
  id="users-pagination"
  page={@page}
  count={@total_rows}
  rows_per_page={@per_page}
  path={&~p"/users?page=#{&1}&per_page=#{&2}"}
/>

<%!-- hide_label: dense, unwrapped variant for an inline filter toolbar --%>
<.pp_input hide_label label="Search" name="q" size="small" />
<.pp_select hide_label label="Status" name="status" prompt="Any" options={["Active", "Archived"]} />
<.pp_number_field field={@form[:quantity]} label="Quantity" min={0} max={10} />

<.pp_checkbox field={@form[:accept]} label="I agree to the terms" />
<.pp_switch field={@form[:notifications]} label="Notifications" />
<.pp_radio_group field={@form[:size]} label="Size" options={[{"Small", "sm"}, {"Large", "lg"}]} />

<.pp_slider name="volume" label="Volume" value={60} />
<.pp_rating id="stars" name="stars" value={3} />

<.pp_button_group>
  <.pp_button variant="outlined">Day</.pp_button>
  <.pp_button variant="outlined">Week</.pp_button>
</.pp_button_group>
<.pp_fab position="fixed" class="bottom-6 right-6">+</.pp_fab>

<%!-- A FAB that fans out related actions on hover / click / focus — pure CSS --%>
<.pp_speed_dial id="create" label="Create" position="fixed" class="bottom-6 right-6">
  <:action label="New workbook" navigate={~p"/workbooks/new"}>
    <.pp_icon name="hero-document-plus" />
  </:action>
  <:action label="Invite teammate" on_click={JS.push("open_invite")}>
    <.pp_icon name="hero-user-plus" />
  </:action>
</.pp_speed_dial>

<.pp_icon name="hero-check" />

<%!-- Phoenix flash (@flash) as Material snackbars — drop once in the root
      layout, where a generated <.flash_group> would go --%>
<.pp_flash_group flash={@flash} />
<.pp_flash_group flash={@flash} auto_hide_duration={4000} />
<%!-- also the "connection lost" chips, replacing the generated <.flash_group> --%>
<.pp_flash_group flash={@flash} connection_notices />

<%!-- Autocomplete, PowerSelect and TransferList need interactive state, so
      they're Phoenix.LiveComponents (LiveView only) instead of pp_* functions --%>
<.live_component module={PhoenixPaper.Autocomplete} id="country" name="country" label="Country" options={["Canada", "Mexico"]} />

<%!-- PowerSelect: a searchable select (ember-power-select) — accent-insensitive
      search, keyboard nav, groups, server search, and multiple selection --%>
<.live_component
  module={PhoenixPaper.PowerSelect}
  id="assignees"
  field={@form[:assignee_ids]}
  label="Assignees"
  multiple
  search={&MyApp.Accounts.search_users/1}
  label_field={:name}
  value_field={:id}
/>
<.live_component module={PhoenixPaper.TransferList} id="permissions" items={["Read", "Write", "Admin"]} />
```

Every component accepts `paperize={false}` to drop PhoenixPaper's classes
entirely and render with only your own `class`; see `AGENTS.md` for the
full contract.

`Button`, `Fab`, `ToggleButton`, and a linked `ListItem` ripple on
click/tap by default (the classic Material feedback effect); pass
`ripple={false}` to turn it off. No JS hook or asset pipeline involved; see
`PhoenixPaper.Ripple`.