Current section
Files
Jump to
Current section
Files
src/ui/menu.gleam
// MIT License
// Copyright (c) 2026 Koncreate
// See LICENSE for details
import gleam/string
import lustre/attribute.{type Attribute, attribute, class, role}
import lustre/element.{type Element}
import lustre/element/html
import ui/css
import ui/size as size_helpers
pub type Variant {
Default
Muted
}
pub type Size {
Small
Medium
Large
}
pub type SelectionMode {
None
Single
Multiple
}
pub fn menu(
attributes: List(Attribute(a)),
children: List(Element(a)),
) -> Element(a) {
html.div(
[
class(
"overflow-auto rounded-md border border-input bg-popover text-popover-foreground",
),
role("menu"),
attribute("aria-orientation", "vertical"),
attribute("tabindex", "-1"),
..attributes
],
[html.ul([class("list-none p-1 m-0")], children)],
)
}
pub fn trigger(
attributes: List(Attribute(a)),
children: List(Element(a)),
) -> Element(a) {
html.button(
[
class(
[
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors",
css.focus_ring(),
"disabled:pointer-events-none " <> css.disabled(),
"bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2",
]
|> string.join(" "),
),
attribute("type", "button"),
attribute("aria-haspopup", "menu"),
..attributes
],
children,
)
}
pub fn menu_section(
attributes: List(Attribute(a)),
heading: String,
children: List(Element(a)),
) -> Element(a) {
html.li([role("presentation"), ..attributes], [
html.div(
[
class(
"px-2 py-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground",
),
role("presentation"),
],
[html.text(heading)],
),
html.ul(
[
class("list-none p-0 m-0"),
role("group"),
attribute("aria-label", heading),
],
children,
),
])
}
pub fn menu_item(
attributes: List(Attribute(a)),
children: List(Element(a)),
) -> Element(a) {
html.li(
[
class(
[
"relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
"hover:bg-accent hover:text-accent-foreground",
"focus:bg-accent focus:text-accent-foreground",
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
]
|> string.join(" "),
),
role("menuitem"),
attribute("tabindex", "-1"),
..attributes
],
children,
)
}
pub fn menu_item_checkbox(
attributes: List(Attribute(a)),
checked: Bool,
children: List(Element(a)),
) -> Element(a) {
html.li(
[
class(
[
"relative flex w-full cursor-default select-none items-center rounded-sm pl-8 pr-2 py-1.5 text-sm outline-none",
"hover:bg-accent hover:text-accent-foreground",
"focus:bg-accent focus:text-accent-foreground",
]
|> string.join(" "),
),
role("menuitemcheckbox"),
attribute("aria-checked", case checked {
True -> "true"
False -> "false"
}),
attribute("tabindex", "-1"),
..attributes
],
children,
)
}
pub fn menu_item_radio(
attributes: List(Attribute(a)),
checked: Bool,
children: List(Element(a)),
) -> Element(a) {
html.li(
[
class(
[
"relative flex w-full cursor-default select-none items-center rounded-sm pl-8 pr-2 py-1.5 text-sm outline-none",
"hover:bg-accent hover:text-accent-foreground",
"focus:bg-accent focus:text-accent-foreground",
]
|> string.join(" "),
),
role("menuitemradio"),
attribute("aria-checked", case checked {
True -> "true"
False -> "false"
}),
attribute("tabindex", "-1"),
..attributes
],
children,
)
}
pub fn separator(attributes: List(Attribute(a))) -> Element(a) {
html.div(
[class("-mx-1 my-1 h-px bg-border"), role("separator"), ..attributes],
[],
)
}
pub fn variant(v: Variant) -> Attribute(a) {
case v {
Default -> class("border-input bg-popover text-popover-foreground")
Muted -> class("border-input bg-muted text-muted-foreground")
}
}
pub fn size(s: Size) -> Attribute(a) {
case s {
Small ->
class("min-w-[10rem] " <> size_helpers.text_class(size_helpers.Small))
Medium ->
class("min-w-[12rem] " <> size_helpers.text_class(size_helpers.Medium))
Large ->
class("min-w-[14rem] " <> size_helpers.text_class(size_helpers.Large))
}
}
pub fn selection_mode(m: SelectionMode) -> Attribute(a) {
case m {
None -> attribute("aria-multiselectable", "false")
Single -> attribute("aria-multiselectable", "false")
Multiple -> attribute("aria-multiselectable", "true")
}
}
pub fn expanded(expanded: Bool) -> Attribute(a) {
case expanded {
True -> attribute("aria-expanded", "true")
False -> attribute("aria-expanded", "false")
}
}
pub fn controls(id: String) -> Attribute(a) {
attribute("aria-controls", id)
}