Packages
mishka_chelekom
0.0.9
0.0.10-alpha.5
0.0.10-alpha.4
0.0.10-alpha.3
0.0.10-alpha.2
0.0.10-alpha.1
0.0.9
0.0.9-rc.2
0.0.9-rc.1
0.0.9-beta.5
0.0.9-beta.4
0.0.9-beta.3
0.0.9-beta.2
0.0.9-beta.1
0.0.9-alpha.20
0.0.9-alpha.18
0.0.9-alpha.17
0.0.9-alpha.16
0.0.9-alpha.15
0.0.9-alpha.14
0.0.9-alpha.13
0.0.9-alpha.12
0.0.9-alpha.11
0.0.9-alpha.10
0.0.9-alpha.9
0.0.9-alpha.8
0.0.9-alpha.7
0.0.9-alpha.6
0.0.9-alpha.5
0.0.9-alpha.4
0.0.9-alpha.3
0.0.9-alpha.2
0.0.9-alpha.1
0.0.8
0.0.8-rc.2
0.0.8-rc.1
0.0.8-beta.5
0.0.8-beta.4
0.0.8-beta.3
0.0.8-beta.2
0.0.8-beta.1
0.0.8-alpha.4
0.0.8-alpha.3
0.0.8-alpha.2
0.0.8-alpha.1
0.0.7
0.0.6
0.0.6-alpha.2
0.0.6-alpha.1
0.0.5
0.0.5-beta.2
0.0.5-beta.1
0.0.5-alpha.12
0.0.5-alpha.11
0.0.5-alpha.10
0.0.5-alpha.9
0.0.5-alpha.8
0.0.5-alpha.7
0.0.5-alpha.6
0.0.5-alpha.5
0.0.5-alpha.4
0.0.5-alpha.3
0.0.5-alpha.2
0.0.5-alpha.1
0.0.4
0.0.4-beta.3
0.0.4-beta.2
0.0.4-beta.1
0.0.4-alpha.9
0.0.4-alpha.8
0.0.4-alpha.7
0.0.4-alpha.6
0.0.4-alpha.5
0.0.4-alpha.4
0.0.4-alpha.3
0.0.4-alpha.2
0.0.4-alpha.1
0.0.3
0.0.3-alpha.3
0.0.3-alpha.2
0.0.3-alpha.1
0.0.2
0.0.2-rc.2
0.0.2-rc.1
0.0.2-beta.4
0.0.2-beta.3
0.0.2-beta.2
0.0.2-beta.1
0.0.2-alpha.3
0.0.2-alpha.2
0.0.2-alpha.1
0.0.1
Mishka Chelekom is a fully featured components and UI kit library for Phoenix & Phoenix LiveView
Current section
Files
Jump to
Current section
Files
priv/headless/menu.eex
defmodule <%= @module %> do
@moduledoc """
Headless **menu** (menu button, Base UI parity) — a `<:trigger>` button opens a
roving-focus menu anchored to it.
Behavior is driven by the dedicated `Menu` JS engine: click (or, with `open_on_hover`,
hover) the `<:trigger>` to open the menu, positioned by `side`/`align`/offset with
edge-flip + viewport clamp; roving highlight + typeahead, Enter/Space activation,
checkbox/radio item state, nested submenus (hover + ArrowRight / ArrowLeft), Escape and
outside-press dismiss, focus restore.
TWO row APIs (mix freely): the idiomatic `<:item type=...>` / `<:submenu>` slots, OR the
composable `menu_item` / `menu_checkbox` / `menu_radio_group` + `menu_radio` / `menu_link`
/ `menu_separator` / `menu_group` / `menu_submenu` components.
Ships **no** colors or spacing. Style the `chelekom-menu*` classes and the
`data-open`/`data-closed`/`data-side`/`data-align`/`data-highlighted`/`data-checked`/
`data-disabled`/`data-popup-open`/`data-pressed`/`data-starting-style` state.
WAI-ARIA APG: https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/
"""
use Phoenix.Component
# ── Root ────────────────────────────────────────────────────────────────
#
# TWO APIs (use whichever you like — you can even mix them):
# • the idiomatic `<:item>` / `<:submenu>` slots (flat rows via a `type` attr), OR
# • the composable `menu_*` row components passed as children (arbitrary order
# + mid-list submenus).
@doc type: :component
attr :id, :string, required: true, doc: "Unique id (anchors aria relationships)"
attr :disabled, :boolean, default: false, doc: "Disable all interaction"
attr :on_open_change, :string, default: nil, doc: "LiveView event pushed on open/close ({open})"
attr :on_open_change_target, :string, default: nil, doc: "Optional pushEventTo target for on_open_change"
attr :side, :string, default: "bottom", values: ~w(top right bottom left), doc: "Preferred side to anchor the popup"
attr :align, :string, default: "start", values: ~w(start center end), doc: "Alignment along the side"
attr :side_offset, :integer, default: 4, doc: "Gap between the trigger and popup (px)"
attr :align_offset, :integer, default: 0, doc: "Offset along the alignment axis (px)"
attr :open_on_hover, :boolean, default: false, doc: "Open on trigger hover (with delay)"
attr :delay, :integer, default: 100, doc: "Hover open delay (ms)"
attr :close_delay, :integer, default: 0, doc: "Hover close delay (ms)"
attr :loop, :boolean, default: true, doc: "Loop arrow-key focus past the ends"
attr :class, :any, default: nil, doc: "Extra classes for the root"
attr :trigger_class, :any, default: nil, doc: "Extra classes for the trigger button part"
attr :popup_class, :any, default: nil, doc: "Extra classes for the popup part"
attr :rest, :global
slot :trigger, required: true, doc: "The button that opens the menu"
slot :check_icon,
doc: "Shared glyph rendered inside every checkbox/radio item indicator (overrides the default ✓/●)"
slot :item, doc: "A menu row (idiomatic slot API)" do
attr :type, :string, doc: "item (default) | checkbox | radio | link | separator | label"
attr :disabled, :boolean
attr :checked, :boolean, doc: "checkbox / radio"
attr :value, :string, doc: "radio value"
attr :name, :string, doc: "radio group name (single-selects items sharing it)"
attr :href, :string, doc: "link href"
attr :label, :string, doc: "group label text / typeahead override"
attr :keep_open, :boolean, doc: "don't close the menu when activated"
attr :on_change, :string, doc: "checkbox/radio change event ({checked}/{value})"
attr :on_change_target, :string
attr :class, :any, doc: "Extra classes for this row part"
attr :indicator_class, :any, doc: "Extra classes for the checkbox/radio indicator part"
end
slot :submenu, doc: "A nested submenu; put its rows (menu_item, ...) inside" do
attr :label, :string, required: true
attr :disabled, :boolean
end
slot :inner_block, doc: "Compose rows with the menu_* components instead of <:item>"
def <%= @component_prefix %>menu(assigns) do
~H"""
<div
id={@id}
phx-hook="Menu"
data-disabled={to_string(@disabled)}
data-side={@side}
data-align={@align}
data-side-offset={@side_offset}
data-align-offset={@align_offset}
data-open-on-hover={to_string(@open_on_hover)}
data-delay={@delay}
data-close-delay={@close_delay}
data-loop={to_string(@loop)}
data-on-open-change={@on_open_change}
data-on-open-change-target={@on_open_change_target}
class={["chelekom-menu", @class]}
{@rest}
>
<button
type="button"
data-part="trigger"
aria-haspopup="menu"
disabled={@disabled}
data-disabled={@disabled}
class={["chelekom-menu__trigger", @trigger_class]}
>
{render_slot(@trigger)}
</button>
<div
id={"#{@id}-popup"}
data-part="popup"
role="menu"
tabindex="-1"
hidden
data-closed
class={["chelekom-menu__popup", @popup_class]}
>
{render_slot(@inner_block)}
<div :for={it <- @item} style="display: contents">
<div :if={it[:type] == "separator"} role="separator" data-part="separator" data-orientation="horizontal" aria-orientation="horizontal" class={["chelekom-menu__separator", it[:class]]}></div>
<div :if={it[:type] == "label"} role="presentation" data-part="group-label" class={["chelekom-menu__group-label", it[:class]]}>{render_slot(it)}</div>
<button
:if={it[:type] == "checkbox"}
type="button"
role="menuitemcheckbox"
data-part="checkbox-item"
aria-checked={to_string(it[:checked] == true)}
data-checked={it[:checked] == true}
data-unchecked={it[:checked] != true}
data-disabled={it[:disabled] == true}
data-label={it[:label]}
data-on-change={it[:on_change]}
data-on-change-target={it[:on_change_target]}
tabindex="-1"
class={["chelekom-menu__checkbox-item", it[:class]]}
>
<span data-part="checkbox-item-indicator" aria-hidden="true" data-checked={it[:checked] == true} data-unchecked={it[:checked] != true} class={["chelekom-menu__indicator", it[:indicator_class]]}>{if @check_icon != [], do: render_slot(@check_icon), else: "✓"}</span>
{render_slot(it)}
</button>
<button
:if={it[:type] == "radio"}
type="button"
role="menuitemradio"
data-part="radio-item"
data-value={it[:value]}
data-radio-group={it[:name]}
aria-checked={to_string(it[:checked] == true)}
data-checked={it[:checked] == true}
data-unchecked={it[:checked] != true}
data-disabled={it[:disabled] == true}
data-label={it[:label]}
data-on-change={it[:on_change]}
data-on-change-target={it[:on_change_target]}
tabindex="-1"
class={["chelekom-menu__radio-item", it[:class]]}
>
<span data-part="radio-item-indicator" aria-hidden="true" data-checked={it[:checked] == true} data-unchecked={it[:checked] != true} class={["chelekom-menu__indicator", it[:indicator_class]]}>{if @check_icon != [], do: render_slot(@check_icon), else: "●"}</span>
{render_slot(it)}
</button>
<a :if={it[:type] == "link"} href={it[:href]} role="menuitem" data-part="link-item" data-label={it[:label]} tabindex="-1" class={["chelekom-menu__link-item", it[:class]]}>{render_slot(it)}</a>
<button
:if={it[:type] in [nil, "item"]}
type="button"
role="menuitem"
data-part="item"
data-disabled={it[:disabled] == true}
data-keep-open={it[:keep_open] == true}
data-label={it[:label]}
tabindex="-1"
class={["chelekom-menu__item", it[:class]]}
>
{render_slot(it)}
</button>
</div>
<div
:for={{sm, idx} <- Enum.with_index(@submenu)}
data-part="submenu"
class="chelekom-menu__submenu"
>
<button
type="button"
role="menuitem"
data-part="submenu-trigger"
aria-haspopup="menu"
aria-expanded="false"
aria-controls={"#{@id}-sub-#{idx}"}
data-disabled={sm[:disabled] == true}
data-label={sm[:label]}
tabindex="-1"
class="chelekom-menu__submenu-trigger"
>
{sm[:label]}
<span data-part="submenu-chevron" aria-hidden="true" class="chelekom-menu__chevron">›</span>
</button>
<div
id={"#{@id}-sub-#{idx}"}
data-part="submenu-popup"
role="menu"
tabindex="-1"
hidden
data-closed
class="chelekom-menu__submenu-popup"
>
{render_slot(sm)}
</div>
</div>
</div>
</div>
"""
end
# ── Item ────────────────────────────────────────────────────────────────
@doc type: :component
attr :disabled, :boolean, default: false
attr :label, :string, default: nil, doc: "Override the text used for typeahead"
attr :keep_open, :boolean, default: false, doc: "Don't close the menu when activated"
attr :class, :any, default: nil
attr :rest, :global, doc: "e.g. phx-click for the action"
slot :inner_block, required: true
def <%= @component_prefix %>menu_item(assigns) do
~H"""
<button
type="button"
role="menuitem"
data-part="item"
data-disabled={@disabled}
data-keep-open={@keep_open}
data-label={@label}
tabindex="-1"
class={["chelekom-menu__item", @class]}
{@rest}
>
{render_slot(@inner_block)}
</button>
"""
end
# ── Checkbox item ─────────────────────────────────────────────────────────
@doc type: :component
attr :checked, :boolean, default: false
attr :disabled, :boolean, default: false
attr :label, :string, default: nil
attr :on_change, :string, default: nil, doc: "LiveView event pushed on toggle ({checked})"
attr :on_change_target, :string, default: nil
attr :class, :any, default: nil
attr :indicator_class, :any, default: nil, doc: "Extra classes for the indicator part"
attr :rest, :global
slot :indicator, doc: "Override the checked glyph"
slot :inner_block, required: true
def <%= @component_prefix %>menu_checkbox(assigns) do
~H"""
<button
type="button"
role="menuitemcheckbox"
data-part="checkbox-item"
aria-checked={to_string(@checked)}
data-checked={@checked}
data-unchecked={!@checked}
data-disabled={@disabled}
data-label={@label}
data-on-change={@on_change}
data-on-change-target={@on_change_target}
tabindex="-1"
class={["chelekom-menu__checkbox-item", @class]}
{@rest}
>
<span
data-part="checkbox-item-indicator"
aria-hidden="true"
data-checked={@checked}
data-unchecked={!@checked}
class={["chelekom-menu__indicator", @indicator_class]}
>{if @indicator != [], do: render_slot(@indicator), else: "✓"}</span>
{render_slot(@inner_block)}
</button>
"""
end
# ── Radio group + radio item ──────────────────────────────────────────────
@doc type: :component
attr :id, :string, default: nil
attr :label, :string, default: nil
attr :class, :any, default: nil
attr :label_class, :any, default: nil, doc: "Extra classes for the group label part"
attr :rest, :global
slot :inner_block, required: true
def <%= @component_prefix %>menu_radio_group(assigns) do
~H"""
<div
role="group"
data-part="radio-group"
aria-labelledby={@label && @id && "#{@id}-label"}
class={["chelekom-menu__radio-group", @class]}
{@rest}
>
<div
:if={@label}
id={@id && "#{@id}-label"}
role="presentation"
data-part="group-label"
class={["chelekom-menu__group-label", @label_class]}
>
{@label}
</div>
{render_slot(@inner_block)}
</div>
"""
end
@doc type: :component
attr :value, :string, required: true
attr :name, :string, default: nil, doc: "Radio group name (single-selects items sharing it; alternative to a radio_group wrapper)"
attr :checked, :boolean, default: false
attr :disabled, :boolean, default: false
attr :label, :string, default: nil
attr :on_change, :string, default: nil, doc: "LiveView event pushed on select ({value})"
attr :on_change_target, :string, default: nil
attr :class, :any, default: nil
attr :indicator_class, :any, default: nil, doc: "Extra classes for the indicator part"
attr :rest, :global
slot :indicator, doc: "Override the selected glyph"
slot :inner_block, required: true
def <%= @component_prefix %>menu_radio(assigns) do
~H"""
<button
type="button"
role="menuitemradio"
data-part="radio-item"
data-value={@value}
data-radio-group={@name}
aria-checked={to_string(@checked)}
data-checked={@checked}
data-unchecked={!@checked}
data-disabled={@disabled}
data-label={@label}
data-on-change={@on_change}
data-on-change-target={@on_change_target}
tabindex="-1"
class={["chelekom-menu__radio-item", @class]}
{@rest}
>
<span
data-part="radio-item-indicator"
aria-hidden="true"
data-checked={@checked}
data-unchecked={!@checked}
class={["chelekom-menu__indicator", @indicator_class]}
>{if @indicator != [], do: render_slot(@indicator), else: "●"}</span>
{render_slot(@inner_block)}
</button>
"""
end
# ── Link item ─────────────────────────────────────────────────────────────
@doc type: :component
attr :href, :string, required: true
attr :label, :string, default: nil
attr :class, :any, default: nil
attr :rest, :global
slot :inner_block, required: true
def <%= @component_prefix %>menu_link(assigns) do
~H"""
<a
href={@href}
role="menuitem"
data-part="link-item"
data-label={@label}
tabindex="-1"
class={["chelekom-menu__link-item", @class]}
{@rest}
>
{render_slot(@inner_block)}
</a>
"""
end
# ── Separator ─────────────────────────────────────────────────────────────
@doc type: :component
attr :orientation, :string, default: "horizontal", values: ~w(horizontal vertical)
attr :class, :any, default: nil
attr :rest, :global
def <%= @component_prefix %>menu_separator(assigns) do
~H"""
<div
role="separator"
data-part="separator"
data-orientation={@orientation}
aria-orientation={@orientation}
class={["chelekom-menu__separator", @class]}
{@rest}
>
</div>
"""
end
# ── Group (with label) ────────────────────────────────────────────────────
@doc type: :component
attr :id, :string, default: nil
attr :label, :string, default: nil
attr :class, :any, default: nil
attr :label_class, :any, default: nil, doc: "Extra classes for the group label part"
attr :rest, :global
slot :inner_block, required: true
def <%= @component_prefix %>menu_group(assigns) do
~H"""
<div
role="group"
data-part="group"
aria-labelledby={@label && @id && "#{@id}-label"}
class={["chelekom-menu__group", @class]}
{@rest}
>
<div
:if={@label}
id={@id && "#{@id}-label"}
role="presentation"
data-part="group-label"
class={["chelekom-menu__group-label", @label_class]}
>
{@label}
</div>
{render_slot(@inner_block)}
</div>
"""
end
# ── Submenu ───────────────────────────────────────────────────────────────
@doc type: :component
attr :id, :string, required: true
attr :label, :string, required: true, doc: "The submenu trigger text"
attr :disabled, :boolean, default: false
attr :class, :any, default: nil
attr :trigger_class, :any, default: nil, doc: "Extra classes for the submenu trigger part"
attr :chevron_class, :any, default: nil, doc: "Extra classes for the submenu chevron part"
attr :popup_class, :any, default: nil, doc: "Extra classes for the submenu popup part"
attr :rest, :global
slot :chevron, doc: "Override the submenu chevron glyph"
slot :inner_block, required: true, doc: "Nested submenu rows"
def <%= @component_prefix %>menu_submenu(assigns) do
~H"""
<div data-part="submenu" class={["chelekom-menu__submenu", @class]} {@rest}>
<button
type="button"
role="menuitem"
data-part="submenu-trigger"
aria-haspopup="menu"
aria-expanded="false"
aria-controls={"#{@id}-sub"}
data-disabled={@disabled}
data-label={@label}
tabindex="-1"
class={["chelekom-menu__submenu-trigger", @trigger_class]}
>
{@label}
<span data-part="submenu-chevron" aria-hidden="true" class={["chelekom-menu__chevron", @chevron_class]}>{if @chevron != [], do: render_slot(@chevron), else: "›"}</span>
</button>
<div
id={"#{@id}-sub"}
data-part="submenu-popup"
role="menu"
tabindex="-1"
hidden
data-closed
class={["chelekom-menu__submenu-popup", @popup_class]}
>
{render_slot(@inner_block)}
</div>
</div>
"""
end
end