Packages

A Material Design component library for Phoenix and LiveView, styled with Tailwind CSS.

Current section

Files

Jump to
phoenix_paper AGENTS.md
Raw

AGENTS.md

# AGENTS.md — PhoenixPaper base rules
PhoenixPaper is a Material Design component library for **Phoenix**, in the
spirit of [ember-paper](https://github.com/miguelcobain/ember-paper) (the
Ember.js Material Design addon), styled with **Tailwind CSS**. It ships as a
hex package (a component library, not a Phoenix app) that a Phoenix project
adds as a dependency.
This file is the ground truth for how the library is built. Read it before
adding or changing a component.
## Project shape
- `lib/phoenix_paper/*.ex` — one module per component (`Button`, `Card`,
`Icon`, `Checkbox`, `Input`, `Switch`, `RadioGroup`, `Select`,
`ButtonGroup`, `ToggleButton`, `Fab`, `Rating`, `Slider`, `NumberField`,
`Autocomplete`, `TransferList`, `AppBar`, `Drawer`, `Breadcrumbs`, `List`, `ListItem`,
`ListSubheader`, `Divider`, `Box`, `Container`, `Stack`, `Grid`,
`GridItem`, `ImageList`, `ImageListItem`, `Paper`, `Typography`, `Table`,
`TableContainer`, `TableHead`, `TableBody`, `TableRow`, `TableCell`,
`TableFooter`, `Alert`, `Backdrop`, `Dialog`, `Progress`, `Skeleton`,
`Snackbar`, `Flash`, `SpeedDial`, `Accordion`, `AccordionSummary`,
`AccordionDetails`, `AccordionActions`, ...), plus `Helpers`, `Elevation`,
`Spacing`, `Shape`, `Ripple`.
- `lib/phoenix_paper/components.ex``use PhoenixPaper.Components` imports
every component's render function at once.
- `priv/static/phoenix_paper.css` — the Tailwind v4 theme (color tokens,
elevation utilities). Consumers `@import` it.
- `test/phoenix_paper/*_test.exs` — one test file per component.
## Component conventions
- One module per component, one public render function named `pp_<name>`
(`pp_button/1`, `pp_card/1`, `pp_checkbox/1`, `pp_icon/1`, ...). The `pp_`
prefix is mandatory — it is what lets `use PhoenixPaper.Components` be
imported into a Phoenix app's `html_helpers` without colliding with the
app's own generated `core_components.ex` (`button/1`, `input/1`, `icon/1`)
or with daisyUI-influenced naming.
- Every component accepts:
- `paperize` (`:boolean`, default `true`) — see the contract below. The
one exception is `PhoenixPaper.Box`, which has no `paperize` attr at
all: it's a bare layout primitive with no default visual style to
strip, so the attr would be a no-op. If a new component genuinely
never applies any built-in classes, drop `paperize` rather than ship a
no-op flag — but that should be rare; almost everything has *some*
skin (even `Stack`/`Grid`/`Container` apply layout classes that
`paperize={false}` legitimately turns off).
- `class` (`:any`, default `nil`) — merged with `Tails`.
- `rest` (`:global`) — for `phx-*` bindings, `id`, `data-*`, etc.
- Register new components in `PhoenixPaper.Components.__using__/1` in the
same change.
- The one-module-per-component rule bends for a small control that's
meaningless without its parent component — `PhoenixPaper.Drawer` exports
both `pp_drawer/1` and its `pp_drawer_toggle/1` hamburger button from
the same module. It doesn't bend for anything independently reusable:
`PhoenixPaper.ListItem` is its own module (and works standalone, e.g.
inside a `Card`) rather than living inside `PhoenixPaper.List`, because a
styled list item is useful on its own. When in doubt, split it out.
## Conditional root tag: link vs. static element
HEEx can't parameterize a tag name (`<{@tag}>` isn't valid), so a component
that should render as `<a>` when it's a link and a plain `<div>`/`<span>`/
`<button>` otherwise (`ListItem`, `Button`) needs two `:if`/`:if={!...}`
branches in the same template, each rendering its own root element. Factor
the shared inner markup into a private function component (e.g. `ListItem`'s
`item_content/1`, `Button`'s `button_content/1`) called from both branches
instead of duplicating it — passing the same `assigns` map through works
fine since it's still just a Phoenix.Component function receiving assigns,
not a macro needing anything special.
`Button`'s link branch (`href`/`navigate`/`patch` set → `Phoenix.Component.link/1`,
MUI's `Button` `href`/`component={Link}`) has one wrinkle `ListItem`'s
doesn't: `<button>` has a native `disabled`, `<a>` doesn't. The
`disabled:opacity-40 disabled:pointer-events-none` in `base_classes/1`
only fires on a real `:disabled` element, so the link branch adds
`pointer-events-none opacity-40` as always-on utilities (`inert_classes/1`)
gated on the same `disabled or loading`, and sets `aria-disabled` instead
of `disabled`. `type` is dropped in link mode. Ripple flows through `<.link>`
unchanged — it already did for `ListItem`, and `onclick` (not `onpointer*`)
is what makes that legal through a function component (see "The ripple
effect").
`Typography` hits the same wall with more branches (`variant="h1"` needs
`<h1>`, `variant="body1"` needs `<p>`, `variant="code"` needs `<code>`, ...)
— it's just one `:if` per distinct *tag* (grouping variants that share a
tag into one `:if={@variant in [...]}`), not per variant, and the inner
content (`render_slot(@inner_block)`) is a single line repeated across
branches rather than factored out, since factoring it here would cost more
than it saves. Unlike MUI's `Typography`, there's no `component` prop to
pick the tag independently of `variant` — one attr driving both keeps this
to 8 branches instead of the cross product of every variant with every
possible tag.
## The ripple effect
`PhoenixPaper.Ripple` implements the Material ripple (a circle expanding
from the click point, then fading) as a small vanilla inline `onclick`
snippet — no JS hook, no bundler, same philosophy as `NumberField`'s
steppers. Every genuinely click-driven component (`Button`, `Fab`,
`ToggleButton`, a linked `ListItem`) exposes a `ripple` boolean attr,
**default `true`**, wired identically:
```elixir
class={Helpers.classes(@paperize, [..., Ripple.container_classes(@ripple)], @class)}
onclick={Ripple.on_click(@ripple)}
```
Two things worth knowing before touching this:
- **It has to be `onclick`, not `onpointerdown`/`onmousedown`.** Phoenix's
HEEx compiler statically validates `on*` attributes against a fixed
allowlist for function components (see
`Phoenix.Component.Declarative`'s `@globals`) — that list has `onclick`
and the `onmouse*` family, but no `onpointer*` events at all. This only
bites when the attribute has to pass through a function component like
`Phoenix.Component.link/1` (which `ListItem` renders through when it's a
link) — a raw HTML tag like `<button>` isn't attr-validated at all, so
`onpointerdown` would have compiled fine on `Button`/`Fab`/`ToggleButton`
specifically, but silently failed to compile the moment the exact same
code was reused on `ListItem`. Using `onclick` everywhere keeps the
helper and its usage identical across every component instead of one
component needing a different event name than the rest.
- **`ripple` is independent of `paperize`** — it's wired outside the
`Helpers.classes/3` gate, so it stays active even under `paperize={false}`
(like `Checkbox`'s hidden-input trick, it's functional/behavioral, not
skin). Only `Ripple.container_classes/1`'s `relative overflow-hidden`
(needed to position/clip the ripple) lives inside `paper_classes` and
gets stripped by `paperize={false}` along with everything else — add
those two classes back yourself via `class` if you want ripple to render
correctly on a de-paperized component.
## `cursor-pointer` on clickable elements
Browsers default `<button>` (and `<select>`) to `cursor: default`, **not**
`pointer` — only `<a href>` gets a pointer cursor for free. Every clickable
non-anchor element a component renders needs an explicit `cursor-pointer`
class, or hovering it gives no visual affordance that it's clickable at
all. This was missed on every `<button>` in the library for a while
(`Button`, `Fab`, `ToggleButton`, `NumberField`'s steppers, `Autocomplete`'s
option button, `TransferList`'s move buttons) before being caught and
fixed — when adding a new component with a raw `<button>` (or `<select>`),
add `cursor-pointer` to its base classes from the start. A `<.link>` or raw
`<a>` doesn't need it (browsers already do this correctly for anchors), and
neither does a non-interactive element — `ListItem`'s static (non-link)
branch deliberately does *not* get `cursor-pointer`, since there's nothing
to click.
## The `paperize` contract
Every component takes a `paperize` boolean attribute, **default `true`**.
- `paperize={true}` (default): the component renders with PhoenixPaper's
Material Design classes (the "paper" skin) — colors, elevation, shape,
typography. A caller-supplied `class` is still merged on top via
`PhoenixPaper.Tails` (last conflicting utility wins), so small tweaks
don't require dropping into `paperize={false}`.
- `paperize={false}`: **all** of the component's built-in classes are
dropped. Only the caller's `class` and `rest` attrs render. The DOM
structure needed for the component to function stays (e.g. the
hidden-input trick on `Checkbox` for unchecked-value submission), but
nothing about its *appearance* is assumed — the caller has a clean slate.
Implementation: `PhoenixPaper.Helpers.classes/3` is the single gate every
component calls through:
```elixir
Helpers.classes(@paperize, paper_classes(...), @class)
```
Never hand-roll this gate inside a component — if `Helpers.classes/3`
doesn't fit a new component's needs, fix it there.
One attr doesn't go through that gate and needs its own handling:
`ripple` (`Button`, `Fab`, `ToggleButton`, `ListItem`) fires via an
`onclick` attribute, not a class, so `Helpers.classes/3` dropping
`paper_classes(...)` under `paperize={false}` doesn't touch it — the
ripple would still fire with nothing to size/clip it. Every ripple-capable
component computes an effective `ripple and paperize` value and uses
*that* everywhere `ripple` would otherwise appear (see
`PhoenixPaper.Ripple`'s moduledoc). Keep that pattern for any new
component that adds `ripple`.
## `PhoenixPaper.Tails`, not plain `Tails` — and updating it when adding a color token
`Helpers.classes/3` merges through `PhoenixPaper.Tails`, a `Tails.Custom`
instance, **never** the plain `Tails` module directly. This isn't
stylistic: plain `Tails` only recognizes Tailwind's own built-in palette
names when deciding whether two classes conflict. It has no idea `pp-*` is
a color family, so when a class sharing a prefix with a `pp-*` color shows
up in the same merge — a font-size utility and `text-pp-*` both start
`text-`; a border/outline *width* utility and `border-pp-*`/`outline-pp-*`
both start `border-`/`outline-`) — it can't tell they're different CSS
properties, lumps them into one "conflicting" group, and silently keeps
only the last one. This was a real, already-shipping bug: `focus-visible:
outline-2` was being dropped by every component using the standard focus
ring pattern (`outline-2` + `outline-pp-primary` together), quietly
shrinking every focus ring in the library to the browser default width.
`PhoenixPaper.Tails` is `Tails.Custom` told about `pp-*` via `color_classes`
specifically to fix this class of bug for good — see its moduledoc.
That configuration lives entirely in `mix.exs`, split across **two
mechanisms that are both required together** — this took two failed
attempts (verified against a real external app depending on this package
via `path:`, not just this package's own `mix test`, since that's what
actually exposed each gap) to land correctly:
- A plain `Application.put_env(:phoenix_paper, PhoenixPaper.Tails,
color_classes: [...])` at the top of `mix.exs`, so the value is visible
when `PhoenixPaper.Tails` itself compiles (`Application.compile_env/2`
reads whatever's in the application environment *at that moment* — a
`config/config.exs` file can't help here at all, since **Mix ignores
`config/config.exs` from dependencies** entirely; `mix.exs` works because
Mix always evaluates a dependency's `mix.exs` first, before any of its
`lib/*.ex`, to learn how to build it).
- The *same* value again, in `application/0`'s `env:` key. This one's
needed because `Application.compile_env/2` doesn't just read a value —
it also makes Mix validate that value against whatever `:phoenix_paper`
is loaded with when the OTP application actually **starts** (e.g. when
`PhoenixPlayground.start/1` boots the full app tree in `dev.exs`).
Application loading resets the app's environment from its compiled
`.app` resource file, discarding the ad-hoc `put_env` from step one —
`env:` is what bakes a value directly into that resource, so it's there
when loading happens. Without it, `mix compile`/`mix test` pass (they
never start the OTP application) but booting a real LiveView server
crashes with a `Mix.Error` about mismatched compile-time/runtime values.
Using only one of the two isn't enough — the `mix.exs` module comment
where both live spells out exactly which failure mode each one alone
leaves open, if you're ever tempted to simplify it back down to one.
**If you ever add a new `pp-*` token** (a new palette color in
`priv/static/phoenix_paper.css`, say), add its name to the shared
`color_classes` list in `mix.exs` too — otherwise it inherits this exact
bug the moment it's combined with a same-prefixed non-color utility.
## Tailwind class safety — no dynamic class names
Tailwind's compiler does not execute Elixir: it scans raw source text for
whole class-name substrings. **A class name built by string interpolation or
concatenation from a runtime value will not be detected**, and Tailwind will
silently omit it from the compiled CSS.
Rules:
- Never write `"bg-#{color}-500"` or `"pp-elevation-#{level}"`-style
interpolation to produce a class name.
- Instead, enumerate every case as a literal string in an explicit
`case`/`cond`/pattern-matched function clause, in a `.ex` file that's
covered by the consumer's Tailwind source scan. See
`PhoenixPaper.Elevation.class/1`, `PhoenixPaper.Spacing.padding/1`, and
`PhoenixPaper.Button`'s `color_classes/2` for the pattern.
- The same applies to variant-prefixed combinations (`hover:pp-elevation-4`)
— the whole prefixed token must appear literally somewhere, not be
assembled at runtime by concatenating a prefix and a helper's return
value. `PhoenixPaper.Button.elevation_classes/2` shows the split: the
common case (`nil` = default elevation) is one literal string with the
hover variant baked in; an explicit override falls back to a plain,
un-animated `Elevation.class/1` call.
## CSS-only interactive state: `peer-*` vs `has-[:checked]:`
Several components (`Checkbox`, `Switch`, `RadioGroup`, `Rating`) fake a
custom-styled control (a box, a track, a circle) around a real, visually
hidden `<input>`, purely in CSS, no JS. Two different Tailwind mechanisms
apply depending on where the styled element sits relative to the input, and
using the wrong one silently does nothing (it doesn't error — it just never
matches, and the "checked" look never appears):
- **`peer-checked:`** (`.peer:checked ~ .peer-checked\:X`, a sibling
combinator) — use this when the styled element is a **sibling** of the
input, both children of the same parent (e.g. `Checkbox`'s checkmark glyph
sitting right after the `.peer` input).
- **`has-[:checked]:`** (`&:has(:checked)`) — use this when the styled
element is an **ancestor** of the input (e.g. `Checkbox`'s outer box
*contains* the input as a child, so it has to react to its own descendant,
not a sibling — `peer-checked:` on that ancestor was a real bug once and
never actually painted the box).
When cascading a fill effect across *multiple* controls sharing one name
(`Rating`'s "hovering star 3 highlights stars 1-3"), the standard
`peer-checked:`/`peer-hover:` sibling-combinator behavior already extends
past the immediate next element to *every later sibling* — so putting a
shared `peer` class on every input/label pair, laid out flat (not nested per
item) and reversed with `flex-row-reverse`, is enough; see `Rating` for the
full pattern and why the elements must be flat siblings, not nested per-star
wrappers.
A third variant, for toggling something from *outside* its DOM subtree
entirely (`Drawer`'s mobile panel, opened by a hamburger button that lives
inside `AppBar`, nowhere near the drawer): a plain `<label for={checkbox_id}>`
checks a checkbox regardless of where the label sits in the document — label
targeting is id-based, not sibling-based. Only the elements that need to
*react* to the checkbox (the drawer panel, its backdrop) have to be its
actual siblings for `peer-checked:` to reach them; the button that flips it
doesn't.
`SpeedDial` layers a fourth thing on top of that same checkbox: the reveal
target reacts to `peer-checked:` **or** `group-hover:` **or**
`group-focus-within:` at once, so one CSS-only component opens on tap
(checkbox, sticky), hover (transient), and keyboard focus. See its own
section under "Actions" for why the trigger↔actions gap must be padding.
## Stateless function components vs. `Phoenix.LiveComponent`
Every component is a stateless `Phoenix.Component` function (`pp_*/1`) by
default — that's the whole point of the `pp_` import convention. Reach for a
`Phoenix.LiveComponent` only when a component needs interactive state a
single render pass can't express from its attrs alone (`Autocomplete`'s open
dropdown + filtered list, `TransferList`'s left/right item split). Those two
are the only such components on purpose: they aren't imported by
`PhoenixPaper.Components` (there's no function to import — they're used
directly as `<.live_component module={PhoenixPaper.Autocomplete} ...} />`),
they only work inside a LiveView (not a plain dead/controller-rendered
page), and that limitation should be called out in their moduledoc. Default
to a stateless function component; justify a `LiveComponent` explicitly.
## Layout primitives (`Box`, `Container`, `Stack`, `Grid`/`GridItem`, `ImageList`/`ImageListItem`)
These are named after and loosely modeled on MUI's Layout category
(mui.com/material-ui) but are Tailwind-native reinterpretations, not ports —
MUI's `sx` prop is a React-specific styled-system feature with no Phoenix
equivalent, and a few things are deliberately narrower than MUI's version
because of the Tailwind class-safety rule above (every responsive/spanning
class has to be a literal, so covering MUI's full breakpoint matrix means
writing out every combination by hand). Notably: `GridItem` only supports a
`md:` breakpoint override, not MUI Grid's full `sm`/`md`/`lg`/`xl` set (see
its moduledoc for why and how to extend it), and `Stack`'s `divider` prop
isn't supported since a stateless component only gets one opaque slot, not
a list of children it could interleave dividers between. `Container`'s
`max_width` uses Tailwind's own `sm`/`md`/`lg`/`xl`/`2xl` screen scale
rather than replicating MUI's specific pixel breakpoints.
## Surfaces and composition (`Paper`, `Card`, `Typography`)
`PhoenixPaper.Paper` is the base surface primitive (background + elevation
+ shape, no padding, no slots) — `Card` is built by composing `Paper`
rather than duplicating its `paper_classes`, matching MUI's real
architecture (`Card` wraps `Paper` there too). When a new component needs
"a raised surface," reach for `<.pp_paper>` instead of hand-rolling
`bg-pp-surface` + `Elevation.class/1` + `Shape.class/1` again.
Composing one PhoenixPaper component inside another needs one extra step
`Card` uses: `Paper` hardcodes `data-pp-component="paper"` on its own root,
and every component is expected to mark itself with *its own* name (see
"Component conventions") — so `Paper` exposes a `component` attr (default
`"paper"`) that a wrapper overrides, e.g. `<.pp_paper component="card">`.
Don't try to override it by passing `data-pp-component="card"` through
`{@rest}` instead — `Paper`'s `<div>` already has that attribute set
literally, so the one from `rest` would just render as an ignored
duplicate rather than replacing it; only the first `data-pp-component` a
browser sees wins.
There is no shipped `CodeSnippet` component — `dev.exs`'s catalog is the
only place PhoenixPaper renders source code, and it does that with
highlight.js (a real, established syntax highlighter) rather than a
hand-rolled component; see "Dev / live preview" below.
## Surfaces: `Accordion`, `AccordionSummary`, `AccordionDetails`, `AccordionActions`
Modeled on MUI's `Accordion` — pure CSS, no JS/LiveView, the same hidden-
checkbox-plus-`peer-checked:` trick as `Drawer`/`Rating`. `pp_accordion/1`
renders the checkbox itself, as the first child inside its own `Paper`
surface; the caller writes `AccordionSummary`/`AccordionDetails`/
`AccordionActions` as its `inner_block`, making them flat siblings *after*
the checkbox (all three need the *same* `id` as `pp_accordion/1`, to build
the matching `for=`/`peer-checked:` wiring — there's no way for sibling
components to discover a shared id implicitly).
Two things worth remembering if you touch this family:
- **`disable_gutters`'s margin needs `has-[:checked]:`, not `peer-checked:`**
— caught this while building it, not after. Every *other* CSS reaction in
this family targets a true sibling of the checkbox (`AccordionSummary`'s
label, `AccordionDetails`, `AccordionActions` — all written by the caller
*after* the checkbox in `pp_accordion/1`'s `inner_block`), so
`peer-checked:` is correct there. But the gutters margin has to land on
`pp_accordion/1`'s own `Paper` root — which is the checkbox's *ancestor*,
not its sibling (the checkbox is rendered *inside* that root, as its own
first child). `peer-checked:` only reaches later siblings of the peer, so
it can't express "this element's own descendant checkbox is checked" —
that needs `has-[:checked]:` instead. Same underlying CSS distinction as
`peer-*` vs `has-[:checked]:` documented above, just easy to get backwards
mid-refactor when three other classes in the same file correctly use
`peer-checked:` for a *different* relationship.
- **Exclusive single-panel groups are `type="radio"`, not JS/LiveView
state.** Give every accordion in a group the same `name` and
`pp_accordion/1` renders a radio instead of a checkbox — same-named radios
are natively mutually exclusive, so "only one open at a time" needs zero
extra code. Verified with real simulated clicks (not just static
rendering) that checking one radio in the group correctly unchecks
whichever was previously open. The one real gap versus MUI's JS-driven
version: a checked radio can't be *unchecked* by clicking it again (an
HTML limitation), so the group can't return to "all collapsed" — that's
documented as a known, permanent difference, not a bug to fix.
## Navigation: `AppBar` (renamed from `Navbar`)
Renamed to match MUI's own component name (`Navbar` was this library's own
earlier, non-MUI name for the same thing) — the rename touched every
reference across the repo: the module/file/test file, `components.ex`'s
import, `Drawer`'s moduledoc example (`AppBar` is the usual home for
`Drawer.pp_drawer_toggle/1`), `dev.exs` (including the live app bar at the
top of that catalog page itself, not just its own demo section), and
`README.md`. If you're hunting for old `Navbar`/`pp_navbar` references
after a `git blame` or an old branch, this is why they're gone.
Added full parity with MUI's `AppBar` props while renaming: `position` now
covers all five MUI values (`static`/`relative`/`sticky`/`fixed`/
`absolute`, not just the original three), `color` gained `"transparent"`
(no background/text-color classes, elevation ignored — MUI's `"inherit"`
was deliberately *not* added alongside it: in a plain-CSS-class component
there's no meaningful difference between "inherit color, keep default
background" and "no color classes at all", so one option covers both), and
a new `variant` attr (`"regular"`/`"dense"`) shrinks the toolbar row the
way MUI's `Toolbar variant="dense"` does. MUI's "prominent" app bar and the
notched bottom-app-bar-with-center-FAB pattern are deliberately not
built-in — neither is a single prop even in MUI itself (both are manual
layout/`sx` compositions in their own docs), so they're documented as
"compose it yourself" in `AppBar`'s moduledoc instead, the same treatment
`Table` gives `TablePagination`/`TableSortLabel`.
`max_width` (`sm`..`2xl`/`full`, default `full`) and `disable_gutters`
were added later, the missing half of "MUI's `Toolbar` inside a
`Container`": the `<header>` background still bleeds edge to edge, but the
inner toolbar row can be capped and centred (`mx-auto w-full
max-w-screen-*`) so its icons/title/actions line up with a `pp_container`
of the same `max_width` in the page body. They live on the inner toolbar
`<div>`, so they're part of that div's **unconditional** layer (below) —
not gated behind `paperize`. The default gutters also became responsive at
the same time (`px-4 sm:px-6` regular, `px-3 sm:px-4` dense), matching
MUI's own `Toolbar` — this is a visual change for existing callers, noted
in the CHANGELOG; `disable_gutters` (→ `px-0`) is the escape hatch.
The inner toolbar `<div>` (the flex row arranging `:leading`/title/
`:actions`) keeps its layout classes **unconditional**, not gated behind
`paperize` like the outer `<header>`'s color/elevation/position classes
are — a deliberate exception to the "paperize={false} drops *all* built-in
classes" wording in the `paperize` contract above. There's no `class` attr
exposed on that inner div for a `paperize={false}` caller to rebuild the
three-region flex layout themselves, so dropping it would leave `:leading`/
title/`:actions` stacked with no arrangement at all and no way back — this
was a deliberate correction after an initial pass wired `Helpers.classes/3`
onto that div too, which would have made `paperize={false}` silently break
the component's basic layout instead of just its skin.
**Pitfall: a `Button`/`Fab`/etc. placed inside a colored `AppBar` needs an
explicit contrasting `class`, or its text is invisible.** `Button`'s
`variant="text"`/`"outlined"` color classes are always the *brand* color
(`text-pp-primary` for `color="primary"`, the default) — they don't know
or care what they're sitting on. Put one inside an `AppBar` with
`color="primary"` (also the default) with no override, and its text color
(`text-pp-primary`) exactly matches the app bar's own background
(`bg-pp-primary`) — not just low-contrast, *mathematically the same
color*, so the button is entirely invisible, not merely hard to read. This
bit `dev.exs`'s own theme-switcher buttons (`Indigo`/`Teal`/`Light`/`Dark`)
in the live top app bar — found from a user screenshot showing a blank
colored bar, not from any test (rendering the class list in isolation
looks completely fine; the bug only exists in the *combination* of two
components' independent, individually-correct defaults). Fixed by adding
`class="text-pp-on-primary hover:bg-pp-on-primary/10 focus-visible:outline-pp-on-primary"`
(or the `border-pp-on-primary`-inclusive variant for `variant="outlined"`)
to each affected button — plain `class` overrides via `Helpers.classes/3`'s
Tails merge, no component change needed, since `Button` has no way to know
its container's color and (like `ButtonGroup`/`Tabs`/`AppBar` itself)
isn't meant to. There's no general fix for this beyond "remember to
override text/border/outline color for brand-colored buttons placed on a
brand-colored surface" — the same caveat applies to any `Button` dropped
into a colored `Drawer` (see below) or `Card`.
## Navigation: `Drawer`'s `color` and reaching into nested `List`/`ListItem`
`Drawer` gained a `color` attr (`primary`/`secondary`/`tertiary`/`surface`,
default `surface` — unchanged prior behavior) so the whole panel can match
a colored `AppBar`. Unlike every other `color` attr in this library
(`Button`, `AppBar`, `Tab`, ...), which only ever touches the component's
*own* classes, `Drawer`'s colored variants also reach into
`List`/`ListItem`/`ListSubheader`/`Divider` — components that have no
`color` prop of their own and are normally styled for a neutral surface
background. This is a deliberate, narrow exception to "no cascading" (see
`ButtonGroup`'s moduledoc for the general rule): getting it wrong here
isn't a style mismatch, it's *actual invisibility*
`ListItem`'s active-item highlight is `bg-pp-primary/10`, and layering
that over a `color="primary"` drawer's own `bg-pp-primary` background is
literally the same color blended with itself, which produces that exact
same color back with zero visible change, not just poor contrast. Found
by screenshotting a colored drawer with an active nav item, the same way
the `AppBar`-button pitfall above was found — a bug that only exists in
the combination of two independently-correct components, invisible from
reading either one's source in isolation.
The mechanism: `color_classes/1` for each brand color adds
`[&_[data-pp-component=list-item]]:text-pp-on-<color>`-style compound
selectors (plus matching ones for `list-subheader` text, `divider`
borders, hover, and the active-item highlight) — the exact same
`data-pp-component` compound-selector technique `Tabs`'s
`variant="full_width"` already uses to reach its child `Tab`s, just with
more targets. `ListItem`'s `active` attr now also sets
`aria-current="page"` (a real, independently-worthwhile accessibility
fix — it's the correct ARIA for "this is the current page in a nav list")
specifically so these selectors have a stable attribute to distinguish
the active item from the rest; without it there'd be no way to target
"the active one" from outside `ListItem` at all. The hover override is
scoped `:not([aria-current=page])` deliberately — omitting that guard
would make the active item's hover state and its always-on
`[aria-current=page]` background compete at *equal* CSS specificity
(one attribute selector each), which falls back to declaration order in
the generated stylesheet and would make the active item's highlight
opacity flicker unpredictably on hover depending on Tailwind's internal
class-authoring order, not anything under this codebase's control.
Because every override is written per-color as a fully literal string
(`color_classes("primary")`, `color_classes("secondary")`, ... — never
`"text-pp-on-#{color}"`), this follows the same "Tailwind class safety"
rule as everywhere else in the codebase, just with three more, longer
literal-string clauses than a typical `color_classes/1`.
## Navigation: `Tabs`, `Tab`, `TabPanel`
The first component family in this library where switching state is
*not* the checkbox/radio-plus-`peer-checked:`/`has-*` trick every other
interactive component (`Accordion`, `Drawer`, `Checkbox`, `Switch`,
`RadioGroup`, `Rating`) uses. `peer-*`/`has-*` can only express "is *some*
sibling checked" — they have no way to express "which *specific* one of N
siblings is checked," which is exactly what mapping a selected `Tab` to
its one matching `TabPanel` needs, especially since the panel usually
isn't even a DOM sibling of the tabs at all (MUI's own docs write
`TabPanel`s *after* the whole `Tabs` block, not interleaved with it, and
this library follows that). So `Tabs`/`Tab`/`TabPanel` switch state with
plain `Phoenix.LiveView.JS` commands (`add_class`/`remove_class`/
`set_attribute`/`show`/`hide`) fired from each `Tab`'s own `phx-click`
same "no server round-trip, no assign to fight with on the next unrelated
re-render" approach `Dialog`/`Drawer` already use for boolean show/hide,
just extended to an *N*-way exclusive choice. `PhoenixPaper.Tabs.select/3`
is the one function building that whole op chain; it's `@doc false`-free
(intentionally public, like `Dialog.show/2`) so a trigger elsewhere on the
page could drive tab selection too.
Verifying this needed a different technique than every other
CSS-driven-toggle component this library has shipped: a static screenshot
only proves the *first-paint* markup/classes are right, it can't prove
the `phx-click` JS-command chain actually flips the right elements when
clicked, because that requires a real `phoenix_live_view.js` client
runtime to interpret. Fully bootstrapping that (a real `LiveSocket`, a
mounted view, a live route) would mean starting a real listening server —
out of bounds for how this repo is verified. Two lighter checks covered
the actual risk instead: (1) a unit test that calls `Tabs.select/3`
directly and pattern-matches the exact `%Phoenix.LiveView.JS{ops: [...]}`
list it returns, so a wrong selector/argument/op-order is caught as a
plain data-structure assertion with no browser involved at all; (2) a
headless-Chromium check that loads the real rendered HTML and manually
replays that *exact* op sequence via `element.classList`/`querySelectorAll`
(not the LiveView JS runtime, just plain DOM calls matching what it would
do), confirming the `data-pp-tabs-id="..."`/`data-pp-tab-panel-group="..."`
selectors actually resolve to the intended elements and only those — the
realistic failure mode here is a typo'd selector or an id built the wrong
way, not `Phoenix.LiveView.JS` itself misbehaving (that's already covered
by LiveView's own test suite).
There's no sliding indicator animation like MUI's — that needs measuring
a specific tab's pixel offset/width at runtime, a genuine client-side
layout query, and `Phoenix.LiveView.JS` only issues fixed DOM commands, no
custom computed logic, without a bespoke JS hook. The selected tab styles
*itself* instead (colored text + a persistent 2px border whose color
toggles), simpler visually but zero custom JS. There's also no roving
`tabindex` (MUI keeps only the selected tab in the normal Tab order) —
every tab stays normally focusable, a small deviation from strict ARIA
tablist authoring practice traded for not needing JS to manage focus too.
Like `ButtonGroup`, there's no group-level `color`/`orientation` that
cascades from `Tabs` down to every `Tab` — HEEx has no mechanism for a
parent component to reach into a child component's own assigns, so both
are set per-`Tab` and must be kept consistent with the parent `Tabs`
yourself.
## Navigation: `Breadcrumbs`
The first slot in this library declared with `attr`s of its own (`slot
:item do attr :href, :any ... end`) — until now every multi-piece
component (`Table`'s rows, `ButtonGroup`'s buttons, `List`'s items) took a
plain opaque `inner_block` and left the caller to write out full child
components. `Breadcrumbs` needs to know things *about* each item (does it
have a link, in what order) to auto-insert a separator and to slice the
list for collapsing, so `:item` carries real attrs instead. Two
implementation traps worth knowing if you add another attr-carrying slot:
- **Slot attrs can't have a `:default`** (a hard compile error if you try)
— unlike top-level `attr`, an omitted, non-required slot attr key is
simply *absent* from the slot entry's map, not filled with `nil`. Dot
access (`item.href`) raises `KeyError` the first time a caller omits it;
bracket access (`item[:href]`) returns `nil` like any other `Map.get/2`
and is the only safe way to read an optional slot attr. Verified by
actually rendering a slot usage that omits the attr, not just reading
the `slot/3` macro's docs — the docs say "an omitted slot will default
to `[]`" (talking about the *slot itself* being absent), which reads
easy to conflate with "an omitted slot *attr*" behaving the same way; it
doesn't.
- A private function component can have several *clauses* pattern-matching
on the shape of `assigns` directly (`defp entry(%{entry: :ellipsis} =
assigns)`, `defp entry(%{entry: {:item, item}} = assigns)`, ...) the same
as any other Elixir function — used here to dispatch a heterogeneous,
server-built list (real items, `:ellipsis`, `:separator` markers,
produced by `Enum.intersperse/2`) to different `~H` templates without a
`case`/`cond` inside one template. Calling a `defp` component via
`<.entry .../>` tag syntax (not just as a plain `{helper(assigns)}` call
like `ListItem`'s `item_content/1`) works fine as long as it's in the
same module — confirmed against `TransferList`'s own private `list/1`,
which already did this.
Which item is "current" (rendered as plain `aria-current="page"` text
instead of a link) is **not** auto-detected by list position — same
"stateless function component, no knowledge of the current request"
reasoning `ListItem`'s `active` attr doc gives. It's simply whichever
`:item` you leave without `href`/`navigate`/`patch`, matching every one of
MUI's own docs examples (their last child is always a plain `Typography`,
never auto-computed either).
Collapsing (`max_items`, default 8, matching MUI) reuses the
hidden-checkbox-plus-`peer-checked:` trick, but unlike `Accordion`/
`Drawer`, the checkbox's `id` is generated internally with
`System.unique_integer/1` rather than taking a caller-supplied `id` attr
at all — nothing outside this component ever needs to reference it (no
sibling summary/details/actions, no external toggle button), so there's
nothing lost by not exposing one. Both the collapsed `<ol>` (first
`items_before_collapse` + ellipsis + last `items_after_collapse`) and the
full `<ol>` are always rendered, one hidden via `peer-checked:hidden`/
`peer-checked:flex` — same "always in the DOM, CSS toggles visibility"
trade-off as `Dialog`. Verified with a real simulated click on the
ellipsis `<label>` (not just a static render) that the checkbox actually
flips and the full list actually becomes visible.
Per the `paperize` contract, per-item *color* (`text-pp-primary` links,
`text-pp-outline` separator, the ellipsis control's hover styles) is
gated through `Helpers.classes/3` like everywhere else — but the `flex
items-center` layout on every `<li>` and both `<ol>`s stays unconditional
even under `paperize={false}`, the same deliberate exception `AppBar`'s
inner toolbar div makes and for the same reason: there's no `class` attr
exposed on an individual `<li>` for a `paperize={false}` caller to rebuild
that row layout themselves.
## Forms: `Slider` (MUI Slider parity)
Rewritten from `accent-color`-only styling to a fully custom
`::-webkit-slider-runnable-track`/`::-webkit-slider-thumb`/
`::-moz-range-track`/`::-moz-range-progress`/`::-moz-range-thumb` skin,
after a real screenshot showed the *unfilled* remainder of an
`accent-color`-only track rendering as a glaring near-white line on a dark
background — `accent-color` alone can color the thumb and the filled
segment, but leaves the unfilled remainder at the browser's own default
color with no way to control it. Verified empirically (not assumed) that
once you take over the track's own background via
`::-webkit-slider-runnable-track`, Chromium stops drawing `accent-color`'s
automatic fill for you at all, so the filled segment here is a manual
`linear-gradient` positioned by a `--pp-slider-percent` CSS custom
property — set inline by `PhoenixPaper.Slider` for the first paint, kept
in sync while dragging by the same "tiny vanilla inline script, no hook,
no bundler" approach `PhoenixPaper.Ripple`/`NumberField`'s steppers
already use. Firefox needs none of that: `::-moz-range-progress` is a
real pseudo-element Firefox sizes to the current value on its own.
All of this custom track/thumb CSS lives in `priv/static/phoenix_paper.css`
as hand-authored `@utility` blocks (matching `pp-elevation-N`/
`pp-skeleton-wave`'s existing pattern), not as Tailwind arbitrary-variant
classes on the component — the declarations needed (multi-stop gradients,
several pseudo-elements, custom properties) are too much for one-class-one-property
arbitrary variants to express cleanly. Four *complete*, mutually-exclusive
"shape" utilities (`pp-slider`/`pp-slider-small` × horizontal,
`pp-slider-vertical`/`pp-slider-vertical-small`) rather than a base
utility plus small per-axis/per-size overrides — two utilities both
setting the same pseudo-element's `background`/sizing from two classes on
one element is a same-specificity, source-order-dependent conflict (the
same class of risk `Drawer`'s colored variants' compound selectors
document above). Verified empirically via `getComputedStyle(...).getPropertyValue(...)`
in headless Chromium that combining a shape utility with a *color*
utility (`pp-slider-primary`, only ever setting `--pp-slider-color`) and a
*track-mode* utility (`pp-slider-track-none`/`-inverted`, only ever
setting `--pp-slider-fill-color`/`--pp-slider-fill-start`/`-end`) resolves
correctly regardless of which is textually later in the stylesheet — these
three categories never set the same custom property as each other, so
there's no analogous conflict between *them*.
Range sliders (`value={{low, high}}`) use the well-known "two overlapping
native range inputs" technique: each input's own track is made fully
transparent (`pp-slider-range-input`, reusing the exact same gradient
formula with both colors set to `transparent` rather than a separate
thumb-only utility), `pointer-events: none` on the input with
`[&::-webkit-slider-thumb]:pointer-events-auto`/
`[&::-moz-range-thumb]:pointer-events-auto` re-enabling it only on the
visible thumb, and a separate sibling `<div>` for the "between the two
thumbs" colored segment (something neither native input's own gradient
can express, since each only knows its *own* value/percent). A small
inline `oninput` script recomputes both thumbs' percentages and the
between-`<div>`'s `left`/`right` on every drag, and clamps a thumb that's
been dragged past the other one. This is a real, inherent limitation of
the technique (shared by every native-input-based range slider, not a
shortcut unique to this implementation) — documented as such in
`PhoenixPaper.Slider`'s own moduledoc rather than treated as a bug to
chase further.
`marks` renders through the native `<datalist>`/`list=` pairing —
real, declarative HTML tick marks (Chrome/Firefox both draw them and snap
the thumb near them), not a hand-rolled overlay. Labeled marks
additionally render a row of absolutely-positioned `<span>`s below the
track, positioned by the same percent math as the fill itself; this
positioning assumes `orientation="horizontal"` and isn't implemented for
vertical sliders.
`orientation="vertical"` uses `writing-mode: vertical-lr` (the current
standards-track way Chromium/WebKit make a range input vertical) plus the
older, still-supported non-standard `-moz-orient: vertical` for Firefox —
there is no vendor-neutral standard property for this yet, so both are
applied together and each engine just ignores the one it doesn't
recognize.
Not ported from MUI: `valueLabelDisplay` (a tooltip tracking the thumb's
*exact pixel position* while dragging) and non-linear `scale` functions —
both need real per-frame JS, which crosses the line from "small inline
snippet" into "bespoke JS hook," the thing this library consistently
avoids. The always-visible `label`/current-value header already gives the
same information `valueLabelDisplay="on"` would, without needing to track
the thumb's pixel position at all.
## Forms: `Input` (MUI TextField parity)
`Input.pp_input/1` is modeled on MUI's TextField: three `variant`s
(`outlined`, `filled`, `standard`), a `color` (`primary`/`secondary`/
`tertiary`/`error`) that only shows up on `:focus-within` (border + label),
a `size` (`medium`/`small`), `multiline`+`rows`, and `:start_adornment`/
`:end_adornment` slots. Not ported from MUI: `select` (that's the separate
`PhoenixPaper.Select` component), `fullWidth` (just put `class="w-full"` on
the caller's own wrapper — no component-level attr needed for one utility
class), `margin` (MUI's `dense`/`normal`/`none` vertical-spacing presets —
this library leaves vertical spacing between form fields to the caller's own
layout, same as everywhere else it uses `Stack`/`gap-*` rather than a
per-component margin attr), and controlled/uncontrolled value semantics
(N/A — LiveView forms already have one way to be "controlled": `field=`
from `to_form/2`).
`hide_label` (also on `Select`) *is* essentially MUI's `hiddenLabel` +
`margin="dense"` rolled together, added because the default field is
unusably tall/wide for an inline filter toolbar: it renders a **separate
`def pp_input(%{hide_label: true} = assigns)` clause** (cleanest — the
notch/floating-label machinery below is intricate enough that threading a
flag through every branch would be a minefield; a whole separate clause
touches none of it) that drops the outer `flex flex-col gap-1` column, the
`<label>`, the `<fieldset>` notch and the helper/error `<p>` rows, uses
`@label` as the `placeholder`, and swaps the asymmetric `pt-7 pb-2`
padding (which only exists to reserve room for the floated label) for a
symmetric `py-2.5`/`py-1.5`. Errors still show — as the red border — just
not the message text, so an error appearing doesn't reflow the toolbar
row. `outlined`'s border thickens on focus the `Select`-wrapper way
(`border``focus-within:border-2`), accepting the 1px shift rather than
carrying the fieldset overlay into the dense path. The `field=` clause
runs first and re-dispatches, so `hide_label` + `field=` composes.
The floating label is the same pure-CSS `peer-*` trick as `Checkbox`/
`Switch`'s state styling (see "CSS-only interactive state" above): the
`<input>`/`<textarea>` is marked `peer` and always rendered with
`placeholder=" "` (a single space, never empty — an empty placeholder
doesn't trigger `:placeholder-shown` in the same reliable way across
browsers), and the label uses `peer-[:not(:placeholder-shown)]:` /
`peer-focus:` to float up. This is *why* adornments had to be added as flex
siblings of the whole `<input>`/`<label>` pair (wrapped together in their
own `relative` div) rather than inside it: `peer-*` only reaches DOM
*siblings* of the marked element, so the input and its label must stay flat
siblings of each other no matter what else the wrapper contains — an
adornment `<span>` sitting between them would have broken every
`peer-focus:`/`peer-[...]:` selector on the label.
`multiline` swaps in a `<textarea rows={@rows}>` for the `<input>` but reuses
every other class/mechanism unchanged — `:placeholder-shown`/`:focus` work
identically on both element types, so the label doesn't need to know or care
which one it's paired with. One easy-to-miss HEEx gotcha here, same class of
bug as `Box`'s `tag="pre"`: the textarea's value must be written as
`<textarea ...>{@value}</textarea>` with **zero whitespace** between the
opening tag's `>` and `{@value}` — a newline there gets preserved as a
leading blank line in every browser's rendering of `<textarea>` content.
`color`'s effect is invisible in a plain (unfocused) screenshot since it's
entirely a `:focus-within` style — this was verified by simulating a real
DOM focus via Chrome DevTools Protocol (`element.focus()` + read
`getComputedStyle`) rather than trusting a static render, the same rigor
applied to any state that only exists on `:hover`/`:focus`/`:active`.
`variant="outlined"`'s border has a real notch cut into it around the
floated label (MUI's "notched outline") via an actual `<fieldset>`/
`<legend>` — not a CSS trick, a genuine browser behavior: a `<fieldset>`
natively draws a gap in its own border around an in-flow `<legend>`, no
custom CSS needed for the gap itself, just color/rounding on top. An
earlier version skipped this (label just floating on an unbroken border
line), which read as visibly off next to a real Material text field — a
side-by-side screenshot comparison caught what unit tests alone didn't,
since "the label is at the right position" and "the border has a
recognizable Material notch" are different, both-look-plausible-in-
isolation claims. The `<fieldset>` is a *sibling* of the input (not an
ancestor — it can't double as the actual flex row arranging adornments/
input/label, because giving it `display: flex` breaks the native
legend-notching behavior entirely, confirmed empirically before
committing to the approach), so connecting "the input has focus/content"
to "open the legend's notch" can't use `peer-*` (which needs true
siblings) — it uses `has-*` from their common ancestor instead, and it
has to be scoped to the real tag
(`has-[input:not(:placeholder-shown)]`/`has-[textarea:not(:placeholder-shown)]`).
The unscoped `has-[:not(:placeholder-shown)]` looks equivalent but isn't:
it also matches the plain `<label>` sitting in the same wrapper (which
vacuously satisfies "not placeholder-shown," the same way any element
that isn't a form control does), so the notch would stay permanently
open regardless of the input's actual state — caught by working through
what the selector matches before shipping it, not from a failing test.
**Follow-up bug, caught from a user screenshot of the supposedly-closed
state**: the legend's `px-1` was originally unconditional, which left a
small permanent gap in the resting (unfocused, empty) border exactly the
width of that padding. Real CSS box-model rule, not a framework quirk:
`max-width` can shrink an element's content toward nothing, but it can
never shrink the element below its own `padding` — a padded box has a
hard floor at `padding-left + padding-right` regardless of how small
`max-width` is set. Fixed by making `px-1` conditional, applied by the
exact same `has-[input:not(:placeholder-shown)]`/`focus-within` triggers
that open `max-width`, so the resting legend has **zero** padding (a true
zero-width box, not just visually small) and the border stays completely
unbroken until the notch actually needs to open.
## Data display: the Table family (`Table`, `TableContainer`, `TableHead`, `TableBody`, `TableRow`, `TableCell`, `TableFooter`)
Modeled on MUI's Table components — one small function component per table
part, composed by the caller (see `PhoenixPaper.Table`'s moduledoc for the
full composition example). No `PhoenixPaper.TablePagination`/
`TableSortLabel``TableCell`'s `sortable`/`sort_direction` attrs give the
sort-header *look* (a clickable header with a direction arrow), but wiring
an actual sort click to actual reordered data is the caller's LiveView, same
as it would be for a hand-rolled `<th>`; a full pagination component wasn't
built at all (flagged as a bigger, separate addition when this family shipped
— composable from existing `Select`/`Button` pieces, but a real interactive
component with its own API decisions, not just another table part).
`dense` (`Table`) and `sticky_header` (`Table`), and `striped` (`TableBody`)
cascade to every descendant cell via plain CSS descendant selectors
(`[&_td]:py-1.5`, `[&>tr:nth-child(even)]:bg-...`) rather than an attr
threaded through every `TableCell` a caller writes — unlike MUI's React
context, HEEx has no mechanism for a parent to reach into a child
component's own assigns (the same limitation `ButtonGroup`'s moduledoc
documents for `color`/`variant`), but a *padding/background* cascade needing
only one class expressible as a selector works here specifically because CSS
descendant selectors match on real DOM nesting, not component boundaries —
`<table>``<td>` is real DOM regardless of which function rendered each.
This trick doesn't generalize to arbitrary-prop cascading like MUI's
`size`/`color`, only to needs a single compound selector can express.
That same trick is also *why* `TableRow`'s `selected` needs
`!bg-pp-primary/10` (Tailwind's important modifier) instead of a plain
`bg-pp-primary/10`: `TableBody`'s `striped` sets its background via
`[&>tr:nth-child(even)]:bg-...`, a compound selector with higher CSS
specificity than a bare class on the row itself, so without `!important` a
selected-and-striped row would silently show the stripe, not the selection
— found by actually screenshotting a selected+striped row, not by reasoning
about specificity in the abstract (see "Tailwind class safety" above for
why every one of these class strings has to be written out literally rather
than interpolated, same rule as everywhere else).
## Data display: `Avatar`, `Badge`, `Chip`, and `Tooltip`
Four MUI-parity components added together, all under "Data display" in
`dev.exs`'s nav.
- **`Avatar`** layers an `<img>` (when `src` is given) over an always-
rendered fallback (the `:inner_block` slot — initials or an icon — or,
failing that, a generic person icon), rather than switching between them
server-side. A vanilla `onerror="this.style.display='none';"` on the
`<img>` (same "small snippet, no hook" precedent as `Ripple`) is what
reveals the fallback on a broken image, matching MUI's own children-on-
error behavior without a LiveView round-trip or an `onError` callback to
wire up. `size` (`small`/`medium`/`large`) is a convenience this library
adds — MUI's own `Avatar` has no size prop, expecting `sx`/`className`
instead. No `AvatarGroup`: the overlapping-stack look is a plain flex
container with `-space-x-*` and a `ring-2 ring-pp-surface` per avatar,
already reachable with Tailwind alone — not a real gap the way
`Snackbar`'s missing queueing is, so no dedicated component for it.
- **`Badge`** overlaps a small count/status indicator on its child's
corner. Hiding logic matches MUI's `Badge` exactly, including a
perhaps-surprising edge case: `invisible or (content == 0 and not
show_zero) or (is_nil(content) and variant == "standard")` — a
`variant="dot"` badge with `content={0}` is hidden by default same as
`standard`, only a `nil` content is the one case `dot` treats specially
(stays visible, e.g. a blank "online" indicator). `color` defaults to
`"error"`, not a `"default"` gray like MUI — this library's `color` scale
is `primary`/`secondary`/`tertiary`/`error` plus `success`/`warning`/
`info` (see `Alert`), no eighth neutral token exists purely for `Badge`,
and an unread-count badge reading as attention-red is the far more common
real case anyway. The wrapping `<span>`'s `relative inline-flex shrink-0`
is a hardcoded literal, not gated by `paperize` — same reasoning as
`Autocomplete`'s anchor `<div class="relative">` (see "The `paperize`
contract" above): it's positioning plumbing the badge can't work without,
not part of the visual skin.
- **`Chip`** picks its root tag the `ListItem` way (see "Conditional root
tag" above): a real `<button>` when `clickable`, a `<div>` otherwise,
sharing one private `chip_content/1`. The one new wrinkle: the delete
control (`deletable`) is deliberately a `<span role="button"
tabindex="0">`, **not** a real `<button>` — nesting a `<button>` inside
`clickable`'s own `<button>` root is invalid HTML (the browser silently
auto-closes the outer one, breaking the chip's whole layout). A tiny
`onkeydown` snippet (Enter/Space → synthetic `.click()`) keeps it
keyboard-operable anyway, same "small vanilla snippet" precedent as
`Ripple`. Its `onclick` also calls `event.stopPropagation()` so deleting
doesn't also fire a `clickable` chip's own click. `color="default"`
(gray, reusing `--color-pp-surface-variant`/`-on-surface`/`-outline`
the same tokens `Input`/`Select`/`NumberField` already use) genuinely is
the default here, unlike `Badge` — a plain tag chip is MUI's most common
real case, and it's neutral, not brand-colored.
- **`Tooltip`** is pure CSS — Tailwind's `group-hover:`/
`group-focus-within:` against a `group` wrapper, no JS/hook at all, not
even a vanilla snippet. `title={nil}`/`title=""` disables it (renders
just the trigger), matching MUI's own `title` prop exactly. No collision
detection/auto-flip like MUI's Popper-based positioning — `placement` is
a fixed offset picked once, one of the 4 cardinal directions instead of
MUI's 12-way matrix.
**A verification dead-end worth knowing about, so it isn't re-walked**:
an early headless-Chromium check of the hover reveal seemed to show
`group-focus-within:opacity-100` losing to the base `opacity-0` utility
once compiled alongside this library's full real stylesheet (thousands
of other classes) — but disappeared as soon as `transition-opacity`
duration was removed from the test. Root cause, confirmed via
`element.getAnimations()`: the element's opacity transition's
`playState` stayed `"running"` forever and never advanced, even past its
150ms duration — headless Chromium's `--virtual-time-budget` fast-forwards
timers but doesn't reliably drive the compositor frames a CSS
*transition* (as opposed to an instant style change) needs to progress.
Every other component's tests in this library check instant class/attr
presence, never an animated property's settled value, which is why this
never came up before. The CSS itself was correct the whole time — nothing
in `Tooltip`, `Badge`, or `Chip` needed changing. If a future headless
check of any *transitioning* property comes back "stuck," suspect the
test methodology before the component.
## Feedback (`Alert`, `Backdrop`, `Dialog`, `Progress`, `Skeleton`, `Snackbar`)
Modeled on MUI's Feedback category. Two things are worth knowing before
touching any of these:
**`Alert`/`Snackbar` needed a new, separate color axis.** Every other
component's `color` attr picks from `primary`/`secondary`/`tertiary`/`error`
— brand/action colors. `Alert`'s `severity` picks from
`success`/`info`/`warning`/`error` — status colors, a different concept that
happens to share the name `error` (and does mean the same red) but has no
brand equivalent for "success" or "info" or "warning". Rather than force
`Alert` onto the existing 4-color scale (which has no green or amber), added
`--color-pp-success`/`-warning`/`-info` (+ `-on-*` pairs) to
`priv/static/phoenix_paper.css`, in both the light (`@theme`) and
`[data-theme="dark"]` blocks — but **not** the `[data-pp-theme="teal"]`
alternate palette, since status colors aren't part of a brand identity swap
and should stay consistent regardless of which brand palette is active. Any
new color token needs adding to `mix.exs`'s `color_classes` list too (see
"`PhoenixPaper.Tails`, not plain `Tails`" above) — these three are as much
a "color token" as `primary`/`secondary`/`tertiary` are, even though they
arrived with a feature addition rather than a new brand color.
**`Dialog` is the one component that isn't stateless-and-simple.** Every
other component in this library either needs no interactivity (most of
them), a tiny bit of pure-CSS trickery (`Drawer`, `Rating`, checkbox/switch
tricks), or genuine server-tracked state as a `Phoenix.LiveComponent`
(`Autocomplete`, `TransferList`). `Dialog` needs client-side show/hide with
transitions, backdrop click-to-close, Escape-to-close, and focus trapping —
none of which need a LiveComponent's server round-trip, so it uses the exact
mechanism `mix phx.new`'s own generated `core_components.ex` modal already
uses: always rendered (hidden via CSS), `Phoenix.LiveView.JS` commands
(`JS.show`/`JS.hide`/`JS.exec`/`JS.focus_first`/`JS.pop_focus`) for the
transitions, and `Phoenix.Component.focus_wrap/1` (a *built-in* Phoenix
component backed by the `Phoenix.FocusWrap` hook that ships with
`phoenix_live_view.js`) for tab-focus trapping — not a hook this library
wrote. If you've used the generated modal before, `PhoenixPaper.Dialog` is
that same shape with Material chrome. The one non-obvious wiring detail:
`data-cancel` has to live on the *outermost* element (the one
`JS.exec("data-cancel", to: "##{id}")` actually targets by CSS selector),
not on the inner `Paper` content — putting it on the wrong element means
`JS.exec` finds nothing and Escape/backdrop-click silently do nothing.
`Progress`'s circular variant is real SVG (`stroke-dasharray`/
`stroke-dashoffset` computed from `value`) only when determinate — the
indeterminate spinner reuses `Button`'s exact bordered-circle
`border-current`/`border-t-transparent`/`animate-spin` trick instead of a
second SVG, since an indeterminate ring doesn't need to represent a real
percentage. `Skeleton`'s `animation="pulse"` is Tailwind's own built-in
`animate-pulse` (nothing to add); `"wave"` needed a real `@keyframes` block
in `priv/static/phoenix_paper.css`, the same as `Progress`'s indeterminate
linear bar — animate-spin/animate-pulse cover the other two, but there's no
built-in Tailwind animation for a sweeping shimmer or a sliding bar.
`Snackbar`'s `anchor_origin` (6 corner/edge positions) and `transition`
(`grow`/`fade`/`slide`/`none`, another set of one-shot `@keyframes`
utilities same as `Skeleton`'s `wave`) came later, matching MUI's own
`Snackbar` page — `transition` only animates the *entrance*; see
`PhoenixPaper.Snackbar`'s moduledoc for why an exit transition and
consecutive-snackbar queueing still aren't built in (each needs either the
`Dialog`-style always-rendered-plus-`JS` machinery, or actual state a
stateless function component has nowhere to hold).
`autoHideDuration` *did* land, as opt-in `auto_hide_duration` (ms) paired
with `on_close` (a `JS` — MUI's close-IconButton pattern, rendered as a
trailing ✕). No JS hook: a standalone zero-footprint `<span
class="pp-snackbar-timeout">` inside the chip runs a no-op `opacity: 1 → 1`
CSS animation of `var(--pp-snackbar-timeout)` duration, and its
`onanimationend` (a raw inline handler — legal because the `<span>` isn't
a function component, same latitude `ThemeToggle`'s `onclick` uses) clicks
the ✕. It's a *child* span, never the root, so its `animation` shorthand
can't clash with the root's own entrance `transition` animation. Still
off by default — the server usually owns "is this message live"
(`Process.send_after/3` clearing the `open` assign); the client timer is
for the no-round-trip case, i.e. `pp_flash_group`.
`positioned` (default `true`) was split out of the all-or-nothing
`paperize` gate: `positioned={false}` drops only the `fixed inset-x-4 …`
anchor classes, keeping the inverted chip/elevation/transition. That's
what lets `PhoenixPaper.Flash` stack several chips inside its own `fixed`
corner container instead of each one anchoring itself to the same spot.
## Feedback: `PhoenixPaper.Flash` (Phoenix flash → snackbars)
`pp_flash_group/1` is the Material counterpart of a generated
`core_components.ex`'s `flash_group/1` — drop it once in the root layout.
It reads `:info`/`:error` (or whatever `kinds` lists) from `@flash` via
`Phoenix.Flash.get/2` and renders one `pp_snackbar positioned={false}` per
present message inside a `fixed` corner stack (`flex flex-col gap-2`).
- **Dismiss needs no LiveView handler.** The ✕ is
`JS.push("lv:clear-flash", value: %{key: kind})``lv:clear-flash` is
handled by the LiveView JS client itself: it clears that flash key and
the server re-renders without it, so the `:if={@message}` on the chip
goes false and it's gone. `auto_hide_duration` threads straight through
to each chip and fires the *same* push on the timer.
- **Monochrome by kind, on purpose.** Material snackbars are a single
inverted surface regardless of severity (the `Snackbar` moduledoc's
own note) — so the kind only picks a leading heroicon
(`icon_name/1`: info/success/warning/error recognised, anything else no
icon), never a background color. Colored severity surfaces = an
`Alert` inside a bare `pp_snackbar`.
- **`role`** is `alert` for `:error`, `status` otherwise.
- The `:client-error`/`:server-error` `phx-disconnected` flashes a
generated `core_components` renders are a *different* mechanism (no
server flash entry) and are out of scope — keep the generated
`<.flash>` for those.
The `stack_classes/1` container is `pointer-events-none` with
`[&_[data-pp-component=snackbar]]:pointer-events-auto` so the transparent
gaps between/around chips don't eat clicks on the page beneath — the same
`data-pp-component` compound-selector reach `Tabs`/`Drawer` use.
## Actions: `PhoenixPaper.SpeedDial`
`pp_speed_dial/1` is MUI's `SpeedDial` + `SpeedDialAction` — a FAB that
fans out `:action` FABs. It's one component (not a `Fab` composed with
separate action components the way MUI splits them) because the actions
have nothing to say in isolation, and the reveal wiring has to be shared.
**The reveal is three CSS mechanisms stacked**, matching how MUI's own
opens three ways:
- **`peer-checked:`** — a hidden `<input type="checkbox" class="peer sr-only">`
rendered as a *direct child of the wrapper* (before both the trigger
`<label for>` and the actions container, so `peer-checked:` reaches both
— the exact `Drawer` layout), toggled by clicking the trigger. This is
the touch path: tap opens, tap again closes, no hover needed.
- **`group-hover:`** — the wrapper is `group`; hovering it (trigger *or*
actions) opens the dial and leaving closes it. The gap between trigger
and first action is `p{t,b,l,r}-4` **padding on the actions container**,
never margin — margin would be a dead zone the pointer crosses and the
dial would flicker shut.
- **`group-focus-within:`** — Tab to the (sr-only but focusable) checkbox
and the dial opens; it stays open as focus moves through the action
links/buttons. `peer-focus-visible:` on the trigger `<label>` paints the
focus ring the sr-only checkbox can't show itself.
Each reveal target carries all three (`peer-checked:opacity-100
group-hover:opacity-100 group-focus-within:opacity-100`, likewise
`pointer-events-auto` and `scale-100`). What this can't do that MUI's JS
can: `aria-expanded` on the trigger (a state a CSS-only component never
names), Esc / true outside-click to close, and per-action hover tooltips
— each `:action`'s `label` is instead an always-visible pill (MUI's
`tooltipOpen`), left of the action for `up`/`down`, above it for
`left`/`right`.
Actions link (`href`/`navigate`/`patch``Phoenix.Component.link/1`) or
button (`on_click``phx-click`) via `speed_dial_action/1`, the same
two-branch split as `Button`'s link mode / `ListItem`. `:icon` defaults to
`hero-plus` and rotates 45° when open (→ `✕`); an `:open_icon` slot
switches that to a cross-fade. Ripple on trigger + actions, `ripple and
paperize` gated like everywhere.
## Theming
Colors are Tailwind v4 theme tokens backed by CSS custom properties, defined
in `priv/static/phoenix_paper.css`:
- `--color-pp-primary`, `--color-pp-secondary`, `--color-pp-tertiary`,
`--color-pp-error`, `--color-pp-surface`, `--color-pp-surface-variant`,
`--color-pp-outline`, and their `pp-on-*` foreground counterparts.
- **Namespaced `pp-` on every token.** This is deliberate: Phoenix apps
commonly ship daisyUI, which defines its own `primary`/`secondary`/
`base-100`/... Tailwind v4 theme colors. Unprefixed names would collide
and whichever stylesheet loads last would silently win. Never add an
unprefixed color token.
- Dark mode keys off `[data-theme="dark"]` — the same attribute daisyUI and
Phoenix 1.8's generated `app.css` already use — so PhoenixPaper flips with
the app's existing toggle instead of adding a second one.
- **"System" default**: when `data-theme` isn't set at all, a
`@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { ... } }`
block (mirroring `[data-theme="dark"]`'s own values) makes the page follow
the OS/browser preference automatically — `dev.exs` and any consuming app
get a correctly-themed first paint with zero clicks. An explicit
`data-theme="dark"` or `data-theme="light"` always wins over the system
preference in either direction; the media query is purely the fallback for
"no explicit choice made yet." `PhoenixPaper.ThemeToggle` (below) is built
around this: it never forces `data-theme` on mount, only on click.
- A second bundled palette (`teal`/`amber`) is opt-in via
`data-pp-theme="teal"` on any ancestor element (typically `<html>`).
- **Custom themes**: don't fork the CSS file. Override the `--color-pp-*`
variables from the consuming app's own stylesheet, after importing
`phoenix_paper.css`. That's the entire theming API — no build step, no
JS config.
## `PhoenixPaper.ThemeToggle`, and a `PhoenixPaper.Tails` gap it exposed
Rewritten from a thin `PhoenixPaper.Switch` wrapper to its own markup so a
sun/moon icon could live *inside* the sliding thumb (swapped via a
`peer-checked:` compound selector reaching into the thumb's own children —
`Switch` itself has no attr for that). Two things worth knowing:
- **Deliberately no `pp-*` brand color anywhere in it** (thumb fixed
white, track a neutral translucent gray) even though `Switch`'s own
thumb/track go `pp-primary` when checked. A theme toggle's single most
common home is an `AppBar` header, which is itself very often
`pp-primary` by default — a `bg-pp-primary` thumb there is the same
"same color layered on itself" invisibility bug already hit for
`Drawer`'s colored variants and for buttons dropped into a colored
`AppBar`. Rather than fix it per-placement (there's no `class` override
path into `Switch`'s internals anyway), the toggle just never uses a
color that could plausibly match its own container.
- **Found a real gap in the vendored `Tails`**: passing `class="size-3"`
to override `PhoenixPaper.Icon`'s default `size-5` left **both** classes
in the merged output (verified directly: `Tails.classes("size-5
size-3")` returns `"size-3 size-5"`, not just `"size-3"`) — this
version of `Tails`'s conflict-resolution ruleset doesn't know about
Tailwind's `size-*` shorthand (a newer utility; the ruleset predates
it), so it doesn't treat two `size-*` classes as conflicting the way it
does e.g. two `text-*` or `bg-*` classes. With both classes present,
which one actually wins is down to Tailwind's own internal utility
ordering in the generated stylesheet — not something to rely on. Fixed
the same way `TableRow`'s `selected` state already does for its own
specificity fight: `class="!size-3"` (Tailwind's `!important` prefix),
which wins regardless of generation order. If you hit visibly-wrong
sizing after overriding an `Icon`'s (or any component's) default size
via `class`, check whether this is why — `Tails` silently keeping both
classes doesn't error or warn, it just produces an ambiguous class list.
- **First version's `<script>`-based system-preference sync looked right
and wasn't** — worth knowing in detail since it's the kind of bug that
only shows up on a real dark-OS machine, never in a static render or a
test. It set the checkbox's `checked` *property* to match
`matchMedia('(prefers-color-scheme: dark)')` on mount (the same "small
vanilla snippet, no hook" precedent `Ripple`/`NumberField` use). That
script itself ran fine — but Phoenix LiveView's connected-mount
hydration re-renders and morphdom-patches the page shortly after the
dead-rendered first paint, and that patch can replace the checkbox
element with a fresh one built from the *server's* render (which has no
way to know the client's OS preference and always has
`default_checked`), silently discarding the script's mutation. Visible
symptom on an actual dark-OS machine: the page renders correctly dark
(the CSS fallback is unaffected by any of this), but the toggle *looks*
set to light — and because the original click handler read the
(silently-reset) `checked` property to decide `"dark"` vs `"light"`, the
first click just reasserted "dark" (a no-op the user couldn't see,
since the page was already dark via the system fallback), so it took
*two* clicks to actually reach light.
Fixed at both ends, neither depending on the other:
1. The click handler now **computes the effective theme itself**
`data-theme` if set, else `matchMedia` — and flips to the opposite,
rather than ever trusting the checkbox's own `checked` property. This
alone fixes the double-click bug regardless of whether anything
synced the checkbox's visual state correctly.
2. The toggle's *first-paint appearance* now syncs via a `@media
(prefers-color-scheme: dark)` block in `priv/static/phoenix_paper.css`
that fakes the "checked" look (track color, thumb position, icon
swap) directly in CSS, scoped to `html:not([data-theme])` — CSS has
no hydration race to lose, so this can't be silently undone the way
the script's property mutation could. It's purely cosmetic (the
underlying checkbox is never actually "checked" until a real click
happens), which is fine because (1) no longer depends on it being
accurate.
One HEEx fact learned along the way, now moot but worth remembering for
next time: `~H` does **not** parse `{...}` interpolation inside a
`<script>` tag's body at all — it's treated as raw text, same as a
browser's own HTML parser treats `<script>`/`<style>` content. A
`{some_function()}` call inside `<script>...</script>` compiled with an
"unused function" warning, meaning it was silently never invoked — the
script body has to be written as literal text directly in the template.
- **Multiple instances now stay in sync, scoped by `target`**: a page with
more than one `pp_theme_toggle` (e.g. one in an `AppBar`, another in a
settings panel) used to leave the other one visually stale after a
click, since each toggle's `onclick` only ever touched its own checkbox.
Fixed by giving the outer `<label>` a `data-pp-target={@target}`
attribute and having the click handler, right after computing `next`,
run `document.querySelectorAll('[data-pp-component="theme-toggle"]
[data-pp-target="..."] input[type=checkbox]')` and set every match's
`checked` property (the clicked checkbox matches its own query too, so
there's no separate `cb.checked = ...` line anymore — one code path
covers both "self" and "siblings"). Scoped to matching `target`, not
every toggle on the page unconditionally: a toggle bound to
`target="#preview"` and one bound to the default `target="html"`
represent two independently-meaningful pieces of state, so syncing them
together would be wrong even though both are `pp_theme_toggle`s. Pure
`querySelectorAll` at click time — no hooks, no PubSub, works across
LiveViews on the same page just as well as within one.
## Elevation
`PhoenixPaper.Elevation.class/1` maps a Material dp level (0-24, clamped) to
a `pp-elevation-N` class. The actual `box-shadow` values live once, in
`priv/static/phoenix_paper.css`, as static `@utility pp-elevation-N` blocks
(a two-layer shadow that approximates — not reproduces exactly — Google's
official umbra/penumbra/ambient elevation table). If pixel-exact MD shadows
are ever needed, replace the CSS values; the Elixir API doesn't change.
## Shape (border radius)
`PhoenixPaper.Shape.class/1,2` maps a token (`:none`, `:xs`, `:sm`, `:md`,
`:lg`, `:xl`, `:full`) to a literal `rounded-*` class, optionally scoped to
an edge (`class(:lg, :top)``"rounded-t-lg"`) for shapes like the filled
text field that only round two corners. `Button` (default `:full`, a pill —
Material's spec) and `Card` (default `:lg`) expose a `shape` attr so callers
can override it; components whose radius isn't meant to be tuned per-call
(e.g. `Checkbox`'s box) just call `Shape.class/1,2` internally without
exposing an attr. Add a `shape` attr to a new component only if a caller
overriding it is actually a reasonable thing to want.
## Spacing
`PhoenixPaper.Spacing` maps named tokens (`:xs`, `:sm`, `:md`, `:lg`, `:xl`,
`:"2xl"`) to literal Tailwind spacing classes (`padding/1`, `gap/1`).
Tailwind's default scale is already a 4px grid, compatible with Material's
8dp baseline grid, so this is a naming layer for consistency (a
project-wide density change becomes a one-file edit), not a new scale.
## Icons
PhoenixPaper does **not** bundle an icon set or an icon hex dependency. Every
`mix phx.new`-generated Phoenix app (1.7+) already vendors heroicons and
wires a Tailwind plugin that turns `hero-*` classes into CSS-mask icons —
reuse that instead of duplicating it. `PhoenixPaper.Icon.pp_icon/1` is a
thin wrapper: `<.pp_icon name="hero-check" />` just renders
`<span class="hero-check ..." />`. Any component that needs an icon
internally (a button's leading icon, a checkbox's checkmark) should accept
a `hero-*` class string the same way, not draw its own SVG.
## Consumer setup (what a project adding this dependency must do)
1. Add `{:phoenix_paper, "~> 0.1"}` to `mix.exs`.
2. In `lib/my_app_web.ex`, add `use PhoenixPaper.Components` to the
`html_helpers` quote block, next to the existing `core_components` import.
3. In `assets/css/app.css`, after `@import "tailwindcss";`, add:
```css
@import "../../deps/phoenix_paper/priv/static/phoenix_paper.css";
@source "../../deps/phoenix_paper/lib";
```
The `@source` line is required — without it Tailwind never scans
PhoenixPaper's `.ex` files and the classes they emit get purged.
## HEEx gotcha: literal `{`/`}` in attribute strings and text
A literal `{...}` inside a HEEx double-quoted attribute value (e.g.
`label="paperize={false} demo"`) or text node is parsed as an **embedded
Elixir expression**, not literal text — it silently corrupts that render
(and can cascade into sibling markup) instead of raising. This bit an
earlier version of this library's own demo copy once. When writing any
string literal a component (or its tests/docs examples) renders, avoid
literal `{`/`}` characters entirely (write `paperize: false`, not
`paperize={false}`).
## More HEEx gotchas: nested heredocs, and `<`/escaped `"` in plain string attrs
Two more ways to corrupt a `~H"""..."""` template without a compile error
that points at the real cause, both hit while building `dev.exs`'s catalog:
- **A `~S"""..."""` (or any other triple-quoted heredoc/sigil) written
directly inside a `~H"""..."""` collides with it.** Elixir's tokenizer
scans for the *outer* heredoc's closing `"""` at the raw character level —
it has no idea it's inside a sigil versus plain code, so the first `"""`
it finds (the nested sigil's own opening or closing delimiter) can get
mistaken for the outer one, breaking heredoc matching for everything
after it. Fix: pull the nested content into its own top-level module
attribute (`@some_code ~S"""..."""`, defined outside any `~H` block) and
reference it inside the template as `{@some_code}` — a plain variable,
no nesting.
- **A literal `<tag>` or an escaped `word=\"value\"` sequence inside a
plain (non-`{}`-wrapped) HEEx attribute string breaks the tokenizer too**
— e.g. `description="...tag=\"span\"..."` fails with "invalid character
in attribute name" even though there's no `<` anywhere near it. The
tokenizer's attribute-value scanner isn't a full string-aware parser; it
reads `<` as tag-start and `identifier="` as attribute-start regardless
of the surrounding quotes. This does **not** affect strings inside a
`{...}`-wrapped attribute value (e.g. `props={[{"key", "a <select>
element"}]}`) — that switches to full Elixir-expression parsing, which
handles escaped quotes normally. Fix: avoid literal `<...>` and escaped
`\"...\"` in plain string attributes; rephrase in prose, use single
quotes for a "quoted" term, or move the string into a `{}`-wrapped
expression/module attribute instead.
Two more, hit writing `Badge`/`Chip`'s moduledocs and `Chip`'s `on_delete`
doc string — plain Elixir gotchas, not HEEx-specific, but easy to trip
into while writing the kind of prose-with-code-samples moduledoc this
library favors:
- **A literal `#{...}`-shaped substring inside a plain `@moduledoc
"""..."""` heredoc is real string interpolation**, not literal text —
`` `"#{max}+"` `` in prose describing `Badge`'s `max` behavior tried to
interpolate a variable named `max` that doesn't exist at the module
level, failing to compile with `undefined variable "max"`. Fix: escape
the `#` as `\#` (`` `"\#{max}+"` ``) whenever a moduledoc's prose needs to
show literal `#{...}` syntax.
- **`~s(...)`/`~S(...)` do not support nested, unescaped parens** — unlike
`"..."` strings, sigils using paired bracket delimiters (`()`, `[]`,
`{}`) only track delimiter *depth* naively; `~s(call("x"))` fails with
"unexpected token: )" because the tokenizer isn't aware the inner `)`
belongs to a string literal, not sigil-closing. Confirmed directly:
`~s(a (b) c)` fails the same way with no strings involved at all — it's
a pure paren-counting issue, nothing to do with quotes specifically. Fix:
pick a sigil delimiter that doesn't appear in the content (`~s[...]` for
content containing parens, as `Chip`'s `on_delete` doc does for its
`JS.push("remove_chip")` example), rather than trying to escape the
inner parens.
## Dev / live preview
`dev.exs` at the project root is a self-contained script (`Mix.install`,
`path: "."` back to this checkout, `phoenix_playground`, and the real
Tailwind v4 CLI via the `tailwind` hex package — see "Tailwind class
safety" isn't relevant here since it compiles for real, not via a CDN) that
boots a real Phoenix + LiveView server rendering a docs-site catalog: a
left `PhoenixPaper.Drawer` for navigation, a sticky `PhoenixPaper.AppBar`,
and one section per component with a live example, its options, and the
HEEx snippet that produced it. Run it with:
```
elixir dev.exs
```
CSS is compiled once at boot (see the file's own header comment for the
`.dev_tailwind_input.css`/`@source` mechanics — same approach as before,
nothing new there), so a **new** Tailwind class name needs a restart to
show up; structural/logic edits still hot-reload live via
`phoenix_live_reload`. When adding a component, add a section for it here
in the same change — a demo, its `props` list, and a `@<name>_code` module
attribute with the snippet — so the catalog stays complete.
**`hero-*` icon names used anywhere in this file must also be added to
`@demo_icon_css`** (a small hand-rolled `mask-image` rule per icon, right
above `@style_tag` — this script has no real asset pipeline, so it can't
get `hero-*` classes for free from `mix phx.new`'s vendored heroicons the
way a real consuming app does). Forgetting this doesn't error or warn
anywhere — `<.pp_icon name="hero-whatever">` renders a perfectly valid,
empty `<span class="hero-whatever">` with no visual definition at all, so
the icon is just silently invisible. This has happened more than once
(`hero-chevron-right`, `hero-user`, `hero-sun-mini`, `hero-moon-mini` were
all added to component demos before being added here, and stayed
invisible until a user reported it) — when adding a demo that uses an
icon name not already in that list, check it first.
Each section's snippet is hidden behind a "Show code"/"Hide code" toggle
(matching MUI's docs-site pattern), implemented the same CSS-only way as
everything else here: a hidden checkbox, a `<label>`, and the code panel
`<div>` as flat siblings (`peer-*` needs that — see above), all inside
`demo_section/1` in the `PhoenixPaperDemo.UI` module. The label itself has
two child `<span>`s ("▸ Show code" / "▾ Hide code") and swaps which one is
visible via an arbitrary child-selector variant on the *label*, keyed off
its own sibling checkbox —
`peer-checked:[&>.pp-show-code]:hidden peer-checked:[&>.pp-hide-code]:inline`
— rather than the usual pattern of `peer-checked:` toggling a single
sibling's own visibility.
The revealed panel is a plain `<pre><code class="language-elixir">{@code}</code></pre>`
(HEEx-escaped, same as before), colored by **highlight.js** plus its
official **highlightjs-copy** plugin (the "Copy" button) — real, established
JS libraries loaded from cdnjs/jsdelivr in `@hljs_assets`, rather than
building highlighting or a copy button by hand.
Two non-obvious things had to be gotten right for this to actually render
colored, not just plain text with a dark background (which looks *close*
enough to "working" at a glance to be easy to ship broken):
- **Language is `elixir`, not `xml`/`html`,** even though every snippet is
a HEEx template and looks tag-shaped. HEEx's function-component syntax
(`<.pp_button ...>`) is not valid XML — a tag name can't start with `.`
— and highlight.js's strict XML/HTML grammar throws on it per element;
hljs catches that internally and silently falls back to *plain,
uncolored* text for that block (still sets `class="hljs"` and
`data-highlighted="yes"`, so nothing *looks* like an error — it just
never colors anything). That hit almost every snippet on this page; only
the couple using bare `<div>`/`<button>` happened to parse as valid XML
and came out colored. `elixir`'s regex-based lexer doesn't choke on `<`
or the leading dot — it just treats them as punctuation — so every
snippet highlights safely, at the cost of not coloring the tags
themselves as tags (there's no dedicated HEEx grammar to reach for;
strings/atoms/keywords/numbers still color correctly). `highlight.min.js`'s
bundled core only ships xml/html/css/js, so the elixir grammar is loaded
separately from `/languages/elixir.min.js`.
- **The init script must run *after* the `<pre><code>` markup exists in
the DOM**, not just after the hljs library loads. `@hljs_assets` is
emitted near the top of the body (next to `@style_tag`), before any code
panel — a classic (non-async/defer) `<script>` pauses the HTML parser
and runs immediately at that point in the document, so calling
`hljs.highlightAll()` directly there finds zero elements every time
(irrelevant that the whole HTTP response already arrived — the parser
still walks it as a left-to-right token stream and hasn't built the
later DOM nodes yet). Wrapping the call in a `DOMContentLoaded` listener
defers it until parsing has finished the whole document. The `<script
src>` tags themselves are fine staying where they are — loading the
libraries early just means they're ready sooner.
`hljs.highlightAll()` runs once, mutating each `<code>`'s DOM (adds
classes, wraps tokens in `<span>`s; the copy plugin inserts a button into
the `<pre>`). That `<pre>` also needs `id={"#{@id}-code"} phx-update="ignore"`
— without it, the flow is: disconnected HTTP render → browser paints plain
text → hljs runs and colors it → LiveSocket connects → LiveView's *first*
connected render has no prior client state to diff against, so it
re-patches the whole page from the server's (unhighlighted) view of the
template, wiping hljs's DOM mutations back to plain text a moment after
they appeared (a visible flash, then revert — the assign-unchanged-means-
untouched optimization only applies to diffs *after* that first connected
patch, not to it). `phx-update="ignore"` tells the client to never patch
that element's subtree at all, at any point, so hljs's mutations are the
only thing that ever touches it. This is the one place this page reaches
an external CDN — see the file's header comment.
## Major gotcha: `assign_new/3` does nothing for an attr that already has a `default`
Found while reworking `Slider``PhoenixPaper.Slider`'s own new `value`
handling raised `MatchError` on a plain `<.pp_slider name="x" />` with no
`value` passed, which led to discovering the **same bug already shipping**
in every other form component's `field=` clause (`Input`, `Checkbox`,
`Switch`, `RadioGroup`, `Rating`, `NumberField`, `Select`): `field=` never
actually populated `name`/`id`/`value`/`checked` from the
`Phoenix.HTML.FormField`. Confirmed by rendering `<.pp_input field={@form[:email]} />`
directly and finding the output `<input>` had no `id`, `name`, or `value`
attribute at all — not a hypothetical, an actual broken render. Zero
existing tests caught it because not one of those seven components' test
files exercised the `field=` code path at all (all now do, and all now
pass — see below).
**The mechanism**: every affected attr (`value`, `name`, `id`, `checked`)
is declared with `attr(..., default: nil)` (or `default: false`,
`default: 0` for `Rating`). Phoenix's `attr` macro fills in that default
value in the compiled `assigns` map at the call site *before* the
component function ever runs — so by the time the field-handling clause
executes `assign_new(:value, fn -> field.value end)`, the key `:value`
**already exists** in assigns (holding `nil`). `Phoenix.Component.assign_new/3`
only computes and sets its fallback when the key is entirely *absent*
(`case assigns do %{^key => _} -> assigns; ... end` — present-with-nil
still matches the first clause and short-circuits). So the fallback
function silently never runs, for *any* attr that also has its own
declared default — which describes essentially every optional attr in
this codebase. `assign_new/3` is the right tool for `Socket` assigns
(its original, intended use — checking whether a LiveView process has
already computed something across renders); it does not do what it looks
like it does here.
**The fix**, applied everywhere this pattern appeared: replace
`assign_new(:key, fn -> field.key end)` with
`assign(:key, assigns.key || field.key)` — plain `||`, not `assign_new`,
so it checks the actual *value* rather than key presence. For a
`:boolean` attr (`Checkbox`/`Switch`'s `checked`) use
`if(is_nil(assigns.checked), do: ..., else: assigns.checked)` instead of
bare `||`, since `false` is a legitimate, meaningful explicit value that
`checked || fallback` would wrongly discard. For `Rating`'s `value`
(declared `default: 0`, not `nil`, since a star rating's natural "unset"
is zero stars) the check is `assigns.value == 0` instead of `is_nil/1`.
None of these fixes can perfectly distinguish "caller explicitly passed
this exact value" from "caller didn't pass it, so the declared default
applied" — that information is genuinely gone by the time the function
body runs, a real limitation of `Phoenix.Component`'s attr system, not
something specific to this library. Treating "value equals the attr's own
declared default" as "not explicitly set" is the best available
approximation, and matches the intent every one of these callers actually
had.
**Test-coverage lesson**: every one of `Input`/`Checkbox`/`Switch`/
`RadioGroup`/`Rating`/`NumberField`/`Select`/`Slider`'s test files now has
a `"field= populates name/id/value(/checked) from the form field"` test,
built via `Phoenix.Component.to_form(%{"key" => "val"}, as: :some_prefix)`
(no `Ecto.Changeset` dependency needed for a simple map-backed form). Add
one of these for any *new* form component that accepts `field=` — it's
the one test category this bug proves "looks obviously fine in the code,
and is checked nowhere" can hide behind.
## Testing
Each component gets `test/phoenix_paper/<name>_test.exs`. Render it with
`Phoenix.LiveViewTest.render_component/1,2` against a tiny private `~H`
wrapper function (see the existing tests for the pattern — this avoids
needing a live endpoint/router just to render a stateless function
component). At minimum, assert:
- The default (`paperize: true`) render includes the expected `pp-*`/
`bg-pp-*` classes.
- `paperize={false}` does **not** include any built-in class, and does
include the caller's `class`.