Howdy

Menu

Dropdown menus: a list of actions opened from a button.

componentimport howdy/ui/menuSince Howdy 2.0
gleam run -m howdy/ui add menu

Dropdown menu

html.div([], [
  ui.button(Outline, ui.menu_trigger("example-menu"), [text("Account")]),
  ui.menu("example-menu", [], [
    ui.menu_label([text("Signed in as ada")]),
    ui.menu_item([], [text("Profile")]),
    ui.menu_checkbox_item(True, [], [text("Compact view")]),
    ui.menu_separator(),
    ui.menu_link("#", [], [text("Sign out")]),
  ]),
])

Choices and submenus

html.div([], [
  ui.button(Outline, ui.menu_trigger("example-view-menu"), [text("View")]),
  ui.menu("example-view-menu", [], [
    ui.menu_radio_group("Sort by", [
      ui.menu_radio_item(True, [], [text("Newest")]),
      ui.menu_radio_item(False, [], [text("Oldest")]),
    ]),
    ui.menu_separator(),
    ui.menu_submenu("example-share-menu", label: [text("Share")], items: [
      menu.item([], [text("Copy link")]),
      menu.item([], [text("Email")]),
    ]),
  ]),
])
Full source of howdy/ui/menu
//// Dropdown menus: a list of actions opened from a button.
////
//// ```gleam
//// ui.button(Outline, menu.trigger("account"), [text("Account")]),
//// menu.menu("account", [], [
////   menu.label([text("Signed in as ada")]),
////   menu.item([event.on_click(OpenProfile)], [text("Profile")]),
////   menu.checkbox_item(model.compact, [event.on_click(ToggleCompact)], [text("Compact view")]),
////   menu.separator(),
////   menu.link("/sign-out", [], [text("Sign out")]),
//// ])
//// ```
////
//// The arrow keys, Home, End and the first letters of an item move between
//// items; Escape, Tab or choosing an item closes the menu and returns focus
//// to the button. The trigger and the menu are tied by id, so both must be
//// in the page or both in one live view.

import howdy/ui/style.{anchor_name, class}
import howdy/ui/theme/tokens
import lustre/attribute.{type Attribute}
import lustre/element.{type Element}
import lustre/element/html
import sketch/css.{type Class}
import sketch/css/length.{rem}

/// Attributes for the button that opens the menu with this id.
pub fn trigger(id: String) -> List(Attribute(msg)) {
  [
    attribute.attribute("popovertarget", id),
    attribute.aria_haspopup("menu"),
    attribute.data("howdy-menu-trigger", ""),
    attribute.data("howdy-anchor", anchor_name(id)),
  ]
}

pub fn menu(
  id: String,
  attributes: List(Attribute(msg)),
  children: List(Element(msg)),
) -> Element(msg) {
  html.div(
    [
      class(menu_class()),
      attribute.id(id),
      attribute.popover("auto"),
      attribute.role("menu"),
      attribute.data("howdy-anchored", anchor_name(id)),
      ..attributes
    ],
    children,
  )
}

/// An action. Disable one with `attribute.aria_disabled(True)`, which
/// keeps it visible and announced but not chosen.
pub fn item(
  attributes: List(Attribute(msg)),
  children: List(Element(msg)),
) -> Element(msg) {
  html.button(
    [
      class(item_class()),
      attribute.type_("button"),
      attribute.role("menuitem"),
      attribute.tabindex(-1),
      ..attributes
    ],
    children,
  )
}

/// An item that goes to another page.
pub fn link(
  href: String,
  attributes: List(Attribute(msg)),
  children: List(Element(msg)),
) -> Element(msg) {
  html.a(
    [
      class(item_class()),
      attribute.href(href),
      attribute.role("menuitem"),
      attribute.tabindex(-1),
      ..attributes
    ],
    children,
  )
}

/// An item that turns a setting on or off, with a tick when it is on.
/// Choosing it flips the tick in the browser; a live view that keeps the
/// setting should update it from the item's click, so the two agree.
pub fn checkbox_item(
  checked: Bool,
  attributes: List(Attribute(msg)),
  children: List(Element(msg)),
) -> Element(msg) {
  html.button(
    [
      class(item_class()),
      attribute.type_("button"),
      attribute.role("menuitemcheckbox"),
      attribute.aria_checked(case checked {
        True -> "true"
        False -> "false"
      }),
      attribute.tabindex(-1),
      ..attributes
    ],
    children,
  )
}

/// Items of which exactly one is chosen, such as a sort order. Label the
/// group; it is announced before its items.
pub fn radio_group(label: String, items: List(Element(msg))) -> Element(msg) {
  html.div([attribute.role("group"), attribute.aria_label(label)], items)
}

/// One choice in a `radio_group`, with a dot when it is the chosen one.
/// Choosing it moves the dot in the browser; update your own state from
/// its click too.
pub fn radio_item(
  checked: Bool,
  attributes: List(Attribute(msg)),
  children: List(Element(msg)),
) -> Element(msg) {
  html.button(
    [
      class(item_class()),
      attribute.type_("button"),
      attribute.role("menuitemradio"),
      attribute.aria_checked(case checked {
        True -> "true"
        False -> "false"
      }),
      attribute.tabindex(-1),
      ..attributes
    ],
    children,
  )
}

/// An item that opens a menu of its own beside it: with the right arrow,
/// Enter, a click, or by pointing at it. The left arrow or Escape closes
/// it again. `id` must be unique in the document.
pub fn submenu(
  id: String,
  label label: List(Element(msg)),
  items items: List(Element(msg)),
) -> Element(msg) {
  // The submenu sits inside its parent, so opening it keeps the parent open.
  html.div([attribute.role("none")], [
    html.button(
      [
        class(item_class()),
        attribute.type_("button"),
        attribute.role("menuitem"),
        attribute.aria_haspopup("menu"),
        attribute.tabindex(-1),
        attribute.attribute("popovertarget", id),
        attribute.data("howdy-submenu-trigger", ""),
        attribute.data("howdy-anchor", anchor_name(id)),
      ],
      label,
    ),
    html.div(
      [
        class(menu_class()),
        class(submenu_class()),
        attribute.id(id),
        attribute.popover("auto"),
        attribute.role("menu"),
        attribute.data("howdy-anchored", anchor_name(id)),
      ],
      items,
    ),
  ])
}

/// A heading for the items after it.
pub fn label(children: List(Element(msg))) -> Element(msg) {
  html.div([class(label_class()), attribute.role("presentation")], children)
}

pub fn separator() -> Element(msg) {
  html.div([class(separator_class()), attribute.role("separator")], [])
}

/// Every class this module uses, for `howdy/ui/export`.
pub fn classes() -> List(Class) {
  [
    menu_class(),
    submenu_class(),
    item_class(),
    label_class(),
    separator_class(),
  ]
}

pub fn menu_class() -> Class {
  css.class([
    css.inset("auto"),
    css.margin_("0.375rem 0"),
    css.property("position-area", "block-end span-inline-end"),
    css.property("position-try-fallbacks", "flip-block, flip-inline"),
    css.property("min-width", "10rem"),
    css.property("max-height", "calc(100vh - 2rem)"),
    css.overflow_y("auto"),
    css.padding(rem(0.25)),
    css.background(tokens.surface),
    css.color(tokens.text),
    style.bordered(),
    style.radius(tokens.radius_medium),
    css.box_shadow("0 10px 30px -10px rgb(0 0 0 / 0.3)"),
  ])
}

pub fn item_class() -> Class {
  css.class([
    css.display("flex"),
    css.align_items("center"),
    css.gap(rem(0.5)),
    css.width(length.percent(100)),
    css.padding_("0.375rem " <> tokens.space_2),
    css.border("0"),
    style.radius(tokens.radius_small),
    css.background("transparent"),
    css.color(tokens.text),
    css.font_family(tokens.font_body),
    css.font_size(rem(0.875)),
    css.line_height("1.25"),
    css.text_align("start"),
    css.text_decoration("none"),
    css.cursor("pointer"),
    css.property("user-select", "none"),
    css.outline("none"),
    css.hover([css.background(tokens.muted)]),
    css.focus([css.background(tokens.muted)]),
    css.selector("[aria-disabled=\"true\"]", [
      css.property("opacity", "0.5"),
      css.cursor("default"),
      css.property("pointer-events", "none"),
    ]),
    // Checkbox and radio items keep room for their mark, so labels line up.
    css.selector("[role=\"menuitemcheckbox\"]", [
      css.property("padding-inline-start", "1.75rem"),
      css.position("relative"),
    ]),
    css.selector("[role=\"menuitemradio\"]", [
      css.property("padding-inline-start", "1.75rem"),
      css.position("relative"),
    ]),
    css.selector("[role=\"menuitemradio\"][aria-checked=\"true\"]::before", [
      css.content("\"\""),
      css.position("absolute"),
      css.property("inset-inline-start", "0.6rem"),
      css.property("top", "50%"),
      css.property("width", "0.4rem"),
      css.property("height", "0.4rem"),
      style.pill(),
      css.background("currentColor"),
      css.transform_("translateY(-50%)"),
    ]),
    // A submenu's item ends with a chevron pointing the way it opens.
    css.selector("[aria-haspopup=\"menu\"]::after", [
      css.content("\"\""),
      css.property("margin-inline-start", "auto"),
      css.property("width", "0.4rem"),
      css.property("height", "0.4rem"),
      css.property("border-right", "2px solid " <> tokens.text_muted),
      css.property("border-bottom", "2px solid " <> tokens.text_muted),
      css.transform_("rotate(-45deg)"),
    ]),
    css.selector(":dir(rtl)[aria-haspopup=\"menu\"]::after", [
      css.transform_("rotate(135deg)"),
    ]),
    css.selector("[aria-expanded=\"true\"]", [css.background(tokens.muted)]),
    css.selector("[role=\"menuitemcheckbox\"][aria-checked=\"true\"]::before", [
      css.content("\"\""),
      css.position("absolute"),
      css.property("inset-inline-start", "0.7rem"),
      css.property("top", "45%"),
      css.property("width", "0.3rem"),
      css.property("height", "0.55rem"),
      css.property("border-right", "2px solid currentColor"),
      css.property("border-bottom", "2px solid currentColor"),
      css.transform_("translateY(-50%) rotate(45deg)"),
    ]),
  ])
}

/// A submenu opens beside its item, or on the other side if there is no
/// room.
pub fn submenu_class() -> Class {
  css.class([
    css.margin_("0 0.25rem"),
    css.property("position-area", "inline-end span-block-end"),
    css.property("position-try-fallbacks", "flip-inline, flip-block"),
  ])
}

pub fn label_class() -> Class {
  css.class([
    css.padding_("0.375rem " <> tokens.space_2),
    css.font_size(rem(0.75)),
    css.font_weight("500"),
    css.color(tokens.text_muted),
  ])
}

pub fn separator_class() -> Class {
  css.class([
    css.height(length.px(1)),
    css.margin_(tokens.space_1 <> " -" <> tokens.space_1),
    css.background(tokens.border),
  ])
}