Packages
petal_components
4.8.0
4.8.0
4.7.0
4.6.2
4.6.1
4.6.0
4.5.0
4.4.0
4.3.0
4.2.1
4.2.0
4.1.2
4.1.1
4.1.0
4.0.12
4.0.11
4.0.10
4.0.6
4.0.5
4.0.4
4.0.3
4.0.1
3.2.2
3.2.1
3.2.0
3.1.0
3.0.2
3.0.1
3.0.0
2.9.3
2.9.2
retired
2.9.1
retired
2.9.0
retired
2.8.4
2.8.3
2.8.2
2.8.1
2.8.0
2.7.4
2.7.3
2.7.2
2.7.1
2.7.0
2.6.1
2.6.0
2.5.2
2.5.1
2.5.0
2.4.3
2.4.2
2.4.1
2.4.0
2.3.0
2.2.1
2.2.0
2.1.2
2.1.1
2.1.0
2.0.6
2.0.5
2.0.4
2.0.3
2.0.2
2.0.1
2.0.0
1.9.3
1.9.2
1.9.1
1.9.0
1.8.0
1.7.1
1.7.0
1.6.2
1.6.1
1.6.0
1.5.5
1.5.4
1.5.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.0
1.2.14
1.2.13
1.2.12
1.2.11
1.2.10
1.2.9
1.2.8
1.2.7
1.2.6
1.2.5
1.2.4
1.2.3
1.2.2
1.2.1
1.2.0
1.1.6
1.1.5
1.1.4
1.1.3
1.1.2
1.1.1
1.1.0
1.0.8
1.0.7
1.0.6
1.0.5
1.0.4
1.0.3
1.0.2
1.0.1
1.0.0
0.19.10
0.19.9
0.19.8
0.19.7
0.19.6
0.19.5
0.19.4
0.19.3
0.19.2
0.19.1
0.19.0
0.18.5
0.18.4
0.18.3
0.18.2
0.18.1
0.18.0
0.17.7
0.17.6
0.17.5
0.17.4
0.17.3
0.17.2
0.17.1
0.17.0
0.16.0
0.15.0
0.14.1
0.14.0
0.13.7
0.13.6
0.13.5
0.13.4
0.13.3
0.13.2
0.13.1
0.13.0
0.12.0
0.11.4
0.11.3
0.11.2
0.11.1
0.11.0
0.10.8
0.10.7
0.10.6
0.10.5
0.10.4
0.10.3
0.10.2
0.10.1
0.10.0
0.9.3
0.9.2
0.9.1
0.9.0
0.8.0
0.7.0
0.6.1
0.6.0
0.5.1
0.5.0
0.4.0
0.3.2
0.3.1
0.3.0
0.2.2
0.2.1
0.2.0
0.1.0
Shadcn-style Phoenix LiveView components that AI assistants can actually use. Pair with the MCP server so AI coding tools can inspect the real component API.
Current section
Files
Jump to
Current section
Files
lib/petal_components/navigation_menu.ex
defmodule PetalComponents.NavigationMenu do
@moduledoc """
A horizontal navigation menu with flyout panels — the pattern marketing
sites use for "Products / Solutions / Resources" menus that hold more than a
simple dropdown can.
Follows the W3C disclosure navigation pattern (recommended for site
navigation over ARIA `menu` roles): each trigger is a `button` with
`aria-expanded`/`aria-controls` and panels contain plain links.
By default panels open on **hover** (the top-nav convention), driven by the
`PetalNavMenu` JS hook: it holds the panel open across the trigger-to-panel
gap with a close grace period, opens on keyboard focus too, and closes on
Escape. Set `trigger="click"` for explicit tap-to-toggle (100% LiveView.JS,
no hook) on touch-first apps, where Escape and clicking away close the panel.
Panels are free-form: compose them from `navigation_menu_link/1` rows
(icon + title + description), an optional `navigation_menu_footer/1` CTA
strip, or any markup you like.
## Staying on screen
In hover mode the hook nudges an open panel horizontally to stay inside the
viewport, so a wide panel near the right edge slides left to fit instead of
spilling off screen. You can still set `align="end"` on an item to anchor its
panel to the trigger's end edge (useful in `click` mode, which has no
repositioning). Panel widths are clamped to the viewport so a wide panel
shrinks to fit on a phone, and a panel taller than the screen scrolls rather
than being cut off at the bottom.
## Full-width (mega menu) panels
Set `full_width` on an item and the panel spans the nearest positioned
ancestor — give your site header `class="relative"` and the panel becomes a
full-width mega menu below it.
"""
use Phoenix.Component
alias Phoenix.LiveView.JS
alias PetalComponents.Link
import PetalComponents.Icon
@transition_in {"transition ease-out duration-200", "opacity-0 translate-y-1",
"opacity-100 translate-y-0"}
@transition_out {"transition ease-in duration-150", "opacity-100 translate-y-0",
"opacity-0 translate-y-1"}
attr :id, :string, required: true
attr :class, :any, default: nil, doc: "extra classes for the nav element"
attr :trigger, :string,
default: "hover",
values: ["hover", "click"],
doc:
"how flyout panels open. hover (default): the PetalNavMenu hook opens on pointer hover or keyboard focus, holds open across the trigger-to-panel gap with a close grace period, and nudges the panel to stay inside the viewport - the convention for a top nav. click: explicit toggle via JS (opens on click, closes on click-away/Escape) - use for touch-first apps that want a deliberate tap"
attr :rest, :global
slot :item, required: true do
attr :label, :string, required: true
attr :to, :string, doc: "render a plain link instead of a flyout trigger"
attr :link_type, :string,
doc: ~s|for plain links: "a" (default), "live_patch" or "live_redirect"|
attr :width, :string, doc: ~s|flyout panel width: "sm", "md" (default), "lg" or "xl"|
attr :align, :string,
doc:
~s|which edge of the trigger the flyout panel anchors to: "start" (default, opens rightward) or "end" (opens leftward). Use "end" for triggers near the right edge so the panel stays on screen.|
attr :full_width, :boolean,
doc: "span the panel across the nearest positioned ancestor (mega menu)"
attr :current, :boolean, doc: "marks the active item with aria-current"
end
@doc """
Renders the navigation menu.
<.navigation_menu id="main-nav">
<:item label="Products" width="md">
<.navigation_menu_link
to="/analytics"
icon="hero-chart-bar"
title="Analytics"
description="Understand your traffic"
/>
<.navigation_menu_link
to="/automations"
icon="hero-arrow-path"
title="Automations"
description="Put repetitive work on autopilot"
/>
<.navigation_menu_footer>
<.navigation_menu_footer_link to="/demo" icon="hero-play-circle" label="Watch demo" />
<.navigation_menu_footer_link to="/contact" icon="hero-phone" label="Contact sales" />
</.navigation_menu_footer>
</:item>
<:item label="Pricing" to="/pricing" />
<:item label="Docs" to="/docs" current />
</.navigation_menu>
For multi-column panels, wrap the links in a grid:
<:item label="Solutions" width="xl">
<div class="grid grid-cols-2 gap-1">
<.navigation_menu_link ... />
<.navigation_menu_link ... />
</div>
</:item>
"""
def navigation_menu(assigns) do
~H"""
<nav
id={@id}
class={["pc-nav-menu", @trigger == "hover" && "pc-nav-menu--hover", @class]}
phx-hook={@trigger == "hover" && "PetalNavMenu"}
phx-click-away={@trigger == "click" && hide_panels(@id)}
phx-window-keydown={@trigger == "click" && hide_panels(@id)}
phx-key={@trigger == "click" && "Escape"}
{@rest}
>
<ul class="pc-nav-menu__list">
<li
:for={{item, index} <- Enum.with_index(@item)}
class={["pc-nav-menu__item", !item[:full_width] && "relative"]}
>
<%= if item[:to] do %>
<Link.a
link_type={item[:link_type] || "a"}
to={item[:to]}
class="pc-nav-menu__link-item"
aria-current={item[:current] && "page"}
>
{item.label}
</Link.a>
<% else %>
<button
type="button"
class="pc-nav-menu__trigger"
aria-expanded="false"
aria-controls={panel_id(@id, index)}
phx-click={@trigger == "click" && toggle_panel(@id, index)}
>
{item.label}
<.icon name="hero-chevron-down-solid" class="pc-nav-menu__chevron" />
</button>
<div
id={panel_id(@id, index)}
class={[
"pc-nav-menu__panel",
panel_width_class(item[:width]),
item[:align] == "end" && "pc-nav-menu__panel--end",
item[:full_width] && "pc-nav-menu__panel--full"
]}
style={@trigger == "click" && "display: none;"}
data-pc-nav-panel
>
{render_slot(item)}
</div>
<% end %>
</li>
</ul>
</nav>
"""
end
attr :to, :string, required: true, doc: "link destination"
attr :title, :string, required: true
attr :description, :string, default: nil
attr :icon, :string, default: nil, doc: "heroicon name, e.g. hero-chart-bar"
attr :link_type, :string,
default: "a",
values: ["a", "live_patch", "live_redirect"]
attr :class, :any, default: nil
attr :rest, :global, include: ~w(method download hreflang ping referrerpolicy rel target type)
@doc """
A flyout panel row: icon in a tinted square, bold title, muted one-line
description.
"""
def navigation_menu_link(assigns) do
~H"""
<Link.a link_type={@link_type} to={@to} class={["pc-nav-menu__link group", @class]} {@rest}>
<div :if={@icon} class="pc-nav-menu__link-icon">
<.icon name={@icon} class="pc-nav-menu__link-icon-svg" />
</div>
<div class="pc-nav-menu__link-body">
<span class="pc-nav-menu__link-title">{@title}</span>
<span :if={@description} class="pc-nav-menu__link-description">{@description}</span>
</div>
</Link.a>
"""
end
attr :class, :any, default: nil
attr :rest, :global
slot :inner_block, required: true
@doc """
A full-bleed strip at the bottom of a flyout panel, usually holding one to
three `navigation_menu_footer_link/1` CTAs.
"""
def navigation_menu_footer(assigns) do
~H"""
<div class={["pc-nav-menu__footer", @class]} {@rest}>
{render_slot(@inner_block)}
</div>
"""
end
attr :to, :string, required: true, doc: "link destination"
attr :label, :string, required: true
attr :icon, :string, default: nil, doc: "heroicon name, e.g. hero-play-circle"
attr :link_type, :string,
default: "a",
values: ["a", "live_patch", "live_redirect"]
attr :rest, :global, include: ~w(method download hreflang ping referrerpolicy rel target type)
@doc """
A compact centered CTA for `navigation_menu_footer/1`.
"""
def navigation_menu_footer_link(assigns) do
~H"""
<Link.a link_type={@link_type} to={@to} class="pc-nav-menu__footer-link" {@rest}>
<.icon :if={@icon} name={@icon} class="pc-nav-menu__footer-link-icon" />
{@label}
</Link.a>
"""
end
defp panel_id(nav_id, index), do: "#{nav_id}-panel-#{index}"
defp panel_width_class("sm"), do: "pc-nav-menu__panel--sm"
defp panel_width_class("lg"), do: "pc-nav-menu__panel--lg"
defp panel_width_class("xl"), do: "pc-nav-menu__panel--xl"
defp panel_width_class(_), do: "pc-nav-menu__panel--md"
# Opening a panel closes every other panel in the same nav first, then
# toggles its own. aria-expanded is kept in sync so CSS (chevron rotation,
# trigger highlight) can key off it.
defp toggle_panel(nav_id, index) do
panel = "##{panel_id(nav_id, index)}"
JS.hide(to: "##{nav_id} [data-pc-nav-panel]:not(#{panel})", transition: @transition_out)
|> JS.set_attribute({"aria-expanded", "false"},
to: "##{nav_id} .pc-nav-menu__trigger:not([aria-controls=\"#{panel_id(nav_id, index)}\"])"
)
|> JS.toggle(to: panel, display: "block", in: @transition_in, out: @transition_out)
|> JS.toggle_attribute({"aria-expanded", "true", "false"})
end
defp hide_panels(nav_id) do
JS.hide(to: "##{nav_id} [data-pc-nav-panel]", transition: @transition_out)
|> JS.set_attribute({"aria-expanded", "false"}, to: "##{nav_id} .pc-nav-menu__trigger")
end
end