Packages

A shadcn/ui-inspired UI library for Gleam and Lustre

Current section

Files

Jump to
glizzy src ui combobox.gleam
Raw

src/ui/combobox.gleam

// MIT License
// Copyright (c) 2026 Koncreate
// See LICENSE for details
import gleam/int
import gleam/option.{type Option, None, Some}
import gleam/string
import lustre/attribute.{type Attribute, attribute, class}
import lustre/element.{type Element}
import lustre/element/html
import ui/css
import ui/keyboard
import ui/size as size_helpers
pub type Variant {
Default
Muted
}
pub type Size {
Small
Medium
Large
}
pub type MenuTrigger {
Input
Focus
Manual
}
pub fn combobox(attributes: List(Attribute(a))) -> Element(a) {
html.div([class("relative")], [
html.input([
class(
[
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm",
"placeholder:text-muted-foreground",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
css.disabled(),
]
|> string.join(" "),
),
attribute("type", "text"),
attribute("role", "combobox"),
attribute("aria-autocomplete", "list"),
attribute("aria-expanded", "false"),
attribute("aria-haspopup", "listbox"),
attribute("aria-controls", "combobox-list"),
attribute("autocomplete", "off"),
attribute("data-combobox-input", "true"),
..attributes
]),
html.button(
[
class(
[
"absolute right-1 top-1/2 -translate-y-1/2",
"inline-flex items-center justify-center",
"h-8 w-8",
"text-muted-foreground",
]
|> string.join(" "),
),
attribute("type", "button"),
attribute("tabindex", "-1"),
attribute("aria-label", "Toggle menu"),
attribute("data-combobox-trigger", "true"),
],
[
chevron_icon([]),
],
),
])
}
pub fn input_field(attributes: List(Attribute(a))) -> Element(a) {
html.input([
class(
[
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm",
"placeholder:text-muted-foreground",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
css.disabled(),
]
|> string.join(" "),
),
attribute("type", "text"),
attribute("role", "combobox"),
attribute("aria-autocomplete", "list"),
attribute("aria-expanded", "false"),
attribute("aria-haspopup", "listbox"),
attribute("autocomplete", "off"),
..attributes
])
}
pub fn listbox(
attributes: List(Attribute(a)),
children: List(Element(a)),
) -> Element(a) {
html.ul(
[
class(
[
"absolute top-full left-0 z-50 mt-1 w-full",
"max-h-60 overflow-auto",
"border bg-background rounded-md shadow-lg p-1",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
]
|> string.join(" "),
),
attribute("role", "listbox"),
attribute("id", "combobox-list"),
attribute("aria-label", "Options"),
..attributes
],
children,
)
}
pub fn option(
attributes: List(Attribute(a)),
label: String,
option_value: String,
) -> Element(a) {
html.li(
[
class(
[
"relative flex items-center cursor-pointer select-none py-2 px-3 text-sm rounded-sm",
"outline-none",
"hover:bg-accent hover:text-accent-foreground",
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
"data-[selected]:bg-primary data-[selected]:text-primary-foreground",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
]
|> string.join(" "),
),
attribute("role", "option"),
attribute("data-value", option_value),
attribute("aria-selected", "false"),
..attributes
],
[html.text(label)],
)
}
pub fn option_group(
attributes: List(Attribute(a)),
label: String,
children: List(Element(a)),
) -> Element(a) {
html.div(
[
class("py-1.5 px-2"),
attribute("role", "group"),
attribute("data-group", "true"),
..attributes
],
[
html.div(
[
class("px-2 py-1.5 text-xs font-semibold text-muted-foreground"),
],
[html.text(label)],
),
html.ul([class("space-y-0.5")], children),
],
)
}
pub fn empty_state(attributes: List(Attribute(a))) -> Element(a) {
html.div(
[
class("py-6 text-center text-sm text-muted-foreground"),
attribute("data-combobox-empty", "true"),
..attributes
],
[],
)
}
pub fn chevron_icon(_attributes: List(Attribute(a))) -> Element(a) {
html.i(
[class("ri ri-arrow-down-s-line h-4 w-4"), attribute("aria-hidden", "true")],
[],
)
}
pub fn variant(v: Variant) -> Attribute(a) {
case v {
Default -> class("border-input bg-background")
Muted -> class("border-input bg-muted")
}
}
pub fn size(s: Size) -> Attribute(a) {
case s {
Small -> class(size_helpers.input_class(size_helpers.Small))
Medium -> class(size_helpers.input_class(size_helpers.Medium))
Large -> class(size_helpers.input_class(size_helpers.Large))
}
}
pub fn menu_trigger(mt: MenuTrigger) -> Attribute(a) {
case mt {
Input -> attribute("data-menu-trigger", "input")
Focus -> attribute("data-menu-trigger", "focus")
Manual -> attribute("data-menu-trigger", "manual")
}
}
pub fn required() -> Attribute(a) {
attribute("aria-required", "true")
}
pub fn disabled() -> Attribute(a) {
attribute("aria-disabled", "true")
}
pub fn invalid() -> Attribute(a) {
attribute("aria-invalid", "true")
}
pub fn placeholder(placeholder: String) -> Attribute(a) {
attribute("placeholder", placeholder)
}
pub fn value(val: String) -> Attribute(a) {
attribute("value", val)
}
pub fn allows_custom_value() -> Attribute(a) {
attribute("data-allows-custom-value", "true")
}
// ============================================================================
// Keyboard Navigation
// ============================================================================
/// Keyboard messages for combobox component.
pub type Msg {
/// Open the dropdown (ArrowDown, ArrowUp, Alt+ArrowDown, Alt+ArrowUp)
Open
/// Close the dropdown (Escape)
Close
/// Move to next option (ArrowDown)
MoveNext
/// Move to previous option (ArrowUp)
MovePrev
/// Move to first option (Home)
MoveFirst
/// Move to last option (End)
MoveLast
/// Select the currently highlighted option (Enter)
Select
/// Type-ahead search (Character keys)
TypeAhead(String)
/// Select and close (Enter)
SelectAndClose
/// Clear the input (Escape when open)
Clear
}
/// Keymap for combobox keyboard navigation.
/// Follows WAI-ARIA combobox pattern:
/// - ArrowDown/ArrowUp: Open dropdown and navigate
/// - Alt+ArrowDown/Alt+ArrowUp: Open dropdown
/// - Escape: Close dropdown
/// - Enter: Select highlighted option
/// - Character: Type-ahead search and filter
pub fn keymap(key_event: keyboard.KeyEvent, is_open: Bool) -> Option(Msg) {
case is_open {
False -> {
// When closed, only open keys work
case keyboard.decode_key(key_event.key) {
keyboard.ArrowDown | keyboard.ArrowUp -> Some(Open)
keyboard.Character(c) -> Some(TypeAhead(c))
_ -> None
}
}
True -> {
// When open, navigation keys work
case keyboard.decode_key(key_event.key) {
keyboard.Escape -> Some(Close)
keyboard.ArrowDown -> Some(MoveNext)
keyboard.ArrowUp -> Some(MovePrev)
keyboard.Home -> Some(MoveFirst)
keyboard.End -> Some(MoveLast)
keyboard.Enter -> Some(SelectAndClose)
keyboard.Character(c) -> Some(TypeAhead(c))
_ -> None
}
}
}
}
/// Get the element ID for a combobox option at the given index.
pub fn option_element_id(index: Int) -> String {
"combobox-option-" <> int.to_string(index)
}
/// Get the element ID for the combobox input.
pub fn input_element_id() -> String {
"combobox-input"
}
/// Get the element ID for the combobox listbox.
pub fn listbox_element_id() -> String {
"combobox-listbox"
}
/// Attribute to indicate expanded state.
pub fn aria_expanded(expanded: Bool) -> Attribute(a) {
attribute("aria-expanded", case expanded {
True -> "true"
False -> "false"
})
}
/// Attribute to indicate the currently active descendant.
pub fn aria_activedescendant(id: String) -> Attribute(a) {
attribute("aria-activedescendant", id)
}
/// Attribute to indicate controls relationship.
pub fn aria_controls(id: String) -> Attribute(a) {
attribute("aria-controls", id)
}