///|
struct ComboboxOption {
value : String
label : String
disabled : Bool
}
///|
pub fn combobox_option(
value~ : String,
label~ : String,
disabled? : Bool = false,
) -> ComboboxOption {
{ value, label, disabled }
}
///|
priv struct ComboboxModel {
open : Bool
query : String
values : Array[String]
active : Int
} derive(Eq)
///|
priv enum ComboboxMsg {
ComboboxOpen
ComboboxToggle
ComboboxNativeChanged(Bool)
ComboboxSetQuery(String)
ComboboxMove(Int)
ComboboxFirst
ComboboxLast
ComboboxHighlight(Int)
ComboboxChoose(Int)
ComboboxRemove(String)
ComboboxClear
ComboboxClose
ComboboxDismiss
}
///|
/// Opaque component-local state supplied to the combobox compound API.
struct ComboboxScope {
id : String
options : Array[ComboboxOption]
model : ComboboxModel
multiple : Bool
placeholder : String
empty_text : String
disabled : Bool
invalid : Bool
required : Bool
name : String?
side : PopupSide
align : PopupAlign
side_offset : Int
align_offset : Int
emit : @cmd.Emit[ComboboxMsg]
content_id : Ref[String]
anchor_id : Ref[String]
has_input : Ref[Bool]
}
///|
const ComboboxRootStyle : String = "position:relative;display:flex;width:100%;min-width:0;flex-direction:column"
///|
const ComboboxControlStyle : String = "display:flex;width:100%;min-height:2.25rem;min-width:0;align-items:center;gap:0.375rem;--rui-control-base-bg:var(--rui-input-background,transparent);--rui-control-base-fg:var(--rui-foreground,oklch(0.145 0 0));--rui-control-base-border:var(--rui-input,oklch(0.922 0 0));--rui-control-base-shadow:0 1px 2px rgb(0 0 0 / 0.05);border:1px solid transparent;border-color:var(--rui-control-border,var(--rui-control-base-border,var(--rui-input,oklch(0.922 0 0))));border-radius:calc(var(--rui-radius,0.625rem) - 0.125rem);background:var(--rui-control-bg,var(--rui-control-base-bg,var(--rui-input-background,transparent)));color:var(--rui-control-fg,var(--rui-control-base-fg,var(--rui-foreground,oklch(0.145 0 0))));padding:0.25rem 0.375rem;box-shadow:var(--rui-control-shadow,var(--rui-control-base-shadow,0 1px 2px rgb(0 0 0 / 0.05)));outline-offset:2px"
///|
const ComboboxControlLayoutStyle : String = "display:flex;width:100%;min-width:0"
///|
const ComboboxChipsStyle : String = "display:flex;width:100%;min-width:0;min-height:2.25rem;flex:1;flex-wrap:wrap;align-items:center;gap:0.375rem;--rui-control-base-bg:var(--rui-input-background,transparent);--rui-control-base-fg:var(--rui-foreground,oklch(0.145 0 0));--rui-control-base-border:var(--rui-input,oklch(0.922 0 0));--rui-control-base-shadow:0 1px 2px rgb(0 0 0 / 0.05);border:1px solid transparent;border-color:var(--rui-control-border,var(--rui-control-base-border,var(--rui-input,oklch(0.922 0 0))));border-radius:calc(var(--rui-radius,0.625rem) - 0.125rem);background:var(--rui-control-bg,var(--rui-control-base-bg,var(--rui-input-background,transparent)));background-clip:padding-box;padding:0.375rem 0.625rem;color:var(--rui-control-fg,var(--rui-control-base-fg,var(--rui-foreground,oklch(0.145 0 0))));box-shadow:var(--rui-control-shadow,var(--rui-control-base-shadow,0 1px 2px rgb(0 0 0 / 0.05)));font-size:0.875rem;line-height:1.25rem;outline:none"
///|
const ComboboxContentStyle : String = "position:fixed;inset:auto;left:var(--rui-floating-left,auto);top:var(--rui-floating-top,auto);z-index:50;display:flex;width:var(--rui-floating-anchor-width,anchor-size(width));min-width:min(calc(var(--rui-floating-anchor-width,anchor-size(width)) + 1.75rem),calc(100vw - 1rem));max-width:var(--rui-floating-available-width,calc(100vw - 1rem));max-height:min(24rem,var(--rui-floating-available-height,calc(100vh - 1rem)));transform-origin:var(--rui-floating-transform-origin,center);flex-direction:column;overflow:hidden;margin:0;border:1px solid color-mix(in oklab,var(--rui-foreground,oklch(0.145 0 0)) 10%,transparent);border-radius:calc(var(--rui-radius,0.625rem) - 0.125rem);background:var(--rui-popover,oklch(1 0 0));padding:0;color:var(--rui-popover-foreground,oklch(0.145 0 0));box-shadow:0 10px 15px -3px rgb(0 0 0 / 0.1),0 4px 6px -4px rgb(0 0 0 / 0.1);outline:none;transition:opacity 100ms ease,transform 100ms ease,visibility 100ms ease"
///|
const ComboboxItemStyle : String = "position:relative;display:flex;width:100%;min-height:2rem;align-items:center;gap:0.5rem;border:0;border-radius:calc(var(--rui-radius,0.625rem) - 0.25rem);background:var(--rui-menu-item-bg,var(--rui-menu-item-base-bg,transparent));padding-block:0.375rem;padding-inline:0.5rem 2rem;color:var(--rui-menu-item-fg,var(--rui-menu-item-base-fg,inherit));font:inherit;font-size:0.875rem;line-height:1.25rem;text-align:start;white-space:nowrap;outline:none;cursor:pointer"
///|
const ComboboxInputBaseStyle : String = "border:0;background:transparent;padding:0.125rem 0.25rem;color:var(--rui-foreground,oklch(0.145 0 0));font-size:0.875rem;line-height:1.25rem;outline:none"
///|
const ComboboxInputLayoutStyle : String = "width:100%;min-width:3rem;flex:1"
///|
const ComboboxChipsInputLayoutStyle : String = "width:auto;min-width:4rem;flex:1"
///|
const ComboboxTriggerIconStyle : String = "display:inline-flex;width:1rem;height:1rem;flex:none;align-items:center;justify-content:center;line-height:0;color:var(--rui-muted-foreground,oklch(0.556 0 0));pointer-events:none"
///|
fn combobox_trigger_icon() -> @html.Html {
@html.span(
attrs=@html.Attrs::build()
.aria_hidden("true")
.data_set("slot", "combobox-trigger-icon"),
style=[ComboboxTriggerIconStyle],
ui_chevron_down_icon(),
)
}
///|
fn combobox_contains(values : Array[String], value : String) -> Bool {
for current in values {
if current == value {
return true
}
}
false
}
///|
fn combobox_option_index(
options : Array[ComboboxOption],
value : String,
) -> Int {
for index, option in options {
if option.value == value {
return index
}
}
-1
}
///|
fn combobox_selected_label(scope : ComboboxScope) -> String {
if scope.model.values.length() == 0 {
return scope.placeholder
}
let labels : Array[String] = []
for value in scope.model.values {
let index = combobox_option_index(scope.options, value)
if index >= 0 {
labels.push(scope.options[index].label)
}
}
labels.join(", ")
}
///|
fn combobox_selected_input_value(
options : Array[ComboboxOption],
values : Array[String],
multiple : Bool,
) -> String {
if multiple || values.length() == 0 {
return ""
}
let index = combobox_option_index(options, values[0])
if index >= 0 {
options[index].label
} else {
""
}
}
///|
fn combobox_filter_query(
options : Array[ComboboxOption],
values : Array[String],
multiple : Bool,
query : String,
) -> String {
let selected_input = combobox_selected_input_value(options, values, multiple)
if !multiple && selected_input != "" && query == selected_input {
""
} else {
query
}
}
///|
fn combobox_matches(option : ComboboxOption, query : String) -> Bool {
query == "" ||
option.label.to_lower().contains(query.to_lower()) ||
option.value.to_lower().contains(query.to_lower())
}
///|
fn combobox_first_match(
options : Array[ComboboxOption],
query : String,
reverse : Bool,
) -> Int {
if reverse {
let mut index = options.length() - 1
while index >= 0 {
if !options[index].disabled && combobox_matches(options[index], query) {
return index
}
index -= 1
}
} else {
for index, option in options {
if !option.disabled && combobox_matches(option, query) {
return index
}
}
}
-1
}
///|
fn combobox_next_match(
options : Array[ComboboxOption],
query : String,
current : Int,
direction : Int,
) -> Int {
let length = options.length()
if length == 0 {
return -1
}
let mut index = current
let mut remaining = length
while remaining > 0 {
index = if direction < 0 {
if index <= 0 {
length - 1
} else {
index - 1
}
} else if index < 0 || index >= length - 1 {
0
} else {
index + 1
}
if !options[index].disabled && combobox_matches(options[index], query) {
return index
}
remaining -= 1
}
-1
}
///|
fn combobox_normalize_values(
options : Array[ComboboxOption],
values : Array[String],
multiple : Bool,
) -> Array[String] {
let result : Array[String] = []
for value in values {
let index = combobox_option_index(options, value)
if index >= 0 &&
!options[index].disabled &&
!combobox_contains(result, value) &&
(multiple || result.length() == 0) {
result.push(value)
}
}
result
}
///|
fn combobox_remove_value(
values : Array[String],
value : String,
) -> Array[String] {
let result : Array[String] = []
for current in values {
if current != value {
result.push(current)
}
}
result
}
///|
fn combobox_toggle_value(
values : Array[String],
value : String,
multiple : Bool,
) -> Array[String] {
if !multiple {
[value]
} else if combobox_contains(values, value) {
combobox_remove_value(values, value)
} else {
let next = values.copy()
next.push(value)
next
}
}
///|
fn combobox_item_id(scope : ComboboxScope, index : Int) -> String {
"\{scope.id}-item-\{index}"
}
///|
fn combobox_notify_open(notify : @cmd.Emit[Bool]?, value : Bool) -> @cmd.Cmd {
if notify is Some(notify) {
notify(value)
} else {
@cmd.none
}
}
///|
fn combobox_notify_query(
notify : @cmd.Emit[String]?,
value : String,
) -> @cmd.Cmd {
if notify is Some(notify) {
notify(value)
} else {
@cmd.none
}
}
///|
fn combobox_notify_values(
on_value_change : @cmd.Emit[String]?,
on_values_change : @cmd.Emit[Array[String]]?,
values : Array[String],
) -> @cmd.Cmd {
let commands : Array[@cmd.Cmd] = []
if on_values_change is Some(notify) {
commands.push(notify(values))
}
if on_value_change is Some(notify) {
commands.push(notify(if values.length() > 0 { values[0] } else { "" }))
}
@cmd.batch(commands)
}
///|
#cfg(target="js")
fn focus_combobox_control(id : String) -> Unit {
guard ui_element_by_id(id + "-root") is Some(root) else { return }
guard ui_find_descendant(root, element => {
let slot = element.get_attribute("data-slot").unwrap_or("")
slot == "combobox-input" ||
slot == "combobox-chip-input" ||
slot == "combobox-trigger"
})
is Some(control) else {
return
}
ui_focus_element(control)
}
///|
#cfg(target="js")
fn combobox_popup_cmd(
id : String,
open : Bool?,
focus_control : Bool,
) -> @cmd.Cmd {
@cmd.custom_cmd(kind=@cmd.after_render, _ => {
let set_layer = () => {
if open is Some(open) {
ui_listbox_set_open_now(id, "combobox-content", open)
}
}
let focus : (() -> Unit)? = if focus_control {
Some(() => focus_combobox_control(id))
} else {
None
}
ui_listbox_run_open_sequence(set_layer, focus)
})
}
///|
#cfg(not(target="js"))
fn combobox_popup_cmd(
id : String,
open : Bool?,
focus_control : Bool,
) -> @cmd.Cmd {
ignore((id, open, focus_control))
@cmd.none
}
///|
#cfg(target="js")
fn bind_combobox_events(id : String, dispatch : (Int) -> Unit) -> Unit {
guard ui_element_by_id(id + "-root") is Some(root) else { return }
root.add_event_listener("focusout", event => {
guard event.to_focus_event() is Some(focus_event) else { return }
let stayed_inside = focus_event.related_target() is Some(related) &&
ui_element_contains_target(root, related)
if root.get_attribute("data-state").unwrap_or("") == "open" &&
!stayed_inside {
dispatch(7)
}
})
}
///|
#cfg(target="js")
fn combobox_root_mousedown(event : @dom.MouseEvent) -> @cmd.Cmd {
guard event.current_target().to_option() is Some(current_target) &&
current_target.to_element() is Some(root) &&
event.target().to_element() is Some(event_target) else {
return @cmd.none
}
if event_target.closest("[data-slot=\"combobox-item\"]") is Some(item) &&
item.closest("[data-slot=\"combobox\"]") is Some(owner) &&
owner.is_same_node(root.as_node()) {
event.prevent_default()
}
guard event_target.closest(
"[data-slot=\"combobox-trigger\"],[data-slot=\"combobox-clear\"]",
)
is Some(action) &&
action.closest("[data-slot=\"combobox\"]") is Some(owner) &&
owner.is_same_node(root.as_node()) else {
return @cmd.none
}
guard root.query_selector(
"[data-slot=\"combobox-input\"],[data-slot=\"combobox-chip-input\"]",
)
is Some(input) else {
return @cmd.none
}
event.prevent_default()
ui_focus_element_without_scroll(input)
@cmd.none
}
///|
#cfg(target="js")
fn combobox_root_click(
scope : ComboboxScope,
event : @dom.MouseEvent,
) -> @cmd.Cmd {
guard event.current_target().to_option() is Some(current_target) &&
current_target.to_element() is Some(root) &&
event.target().to_element() is Some(event_target) else {
return @cmd.none
}
guard event_target.closest(
"[data-slot=\"combobox-input\"],[data-slot=\"combobox-chip-input\"]",
)
is Some(target) &&
target.closest("[data-slot=\"combobox\"]") is Some(owner) &&
owner.is_same_node(root.as_node()) else {
return @cmd.none
}
(scope.emit)(ComboboxOpen)
}
///|
#cfg(target="js")
fn combobox_root_keydown(
scope : ComboboxScope,
event : @dom.KeyboardEvent,
) -> @cmd.Cmd {
if event.alt_key() ||
event.ctrl_key() ||
event.meta_key() ||
event.is_composing() {
return @cmd.none
}
guard event.current_target().to_option() is Some(current_target) &&
current_target.to_element() is Some(root) &&
event.target().to_element() is Some(event_target) else {
return @cmd.none
}
guard event_target.closest(
"[data-slot=\"combobox-input\"],[data-slot=\"combobox-chip-input\"],[data-slot=\"combobox-item\"],[data-slot=\"combobox-trigger\"]",
)
is Some(target) &&
target.closest("[data-slot=\"combobox\"]") is Some(owner) &&
owner.is_same_node(root.as_node()) else {
return @cmd.none
}
let key = event.key()
let slot = target.get_attribute("data-slot").unwrap_or("")
let input = slot == "combobox-input" || slot == "combobox-chip-input"
if key == "Tab" && scope.model.open {
return (scope.emit)(ComboboxDismiss)
}
let message : ComboboxMsg? = match key {
"ArrowDown" => Some(ComboboxMove(1))
"ArrowUp" => Some(ComboboxMove(-1))
"Home" if !input => Some(ComboboxFirst)
"End" if !input => Some(ComboboxLast)
"Enter" if slot == "combobox-item" ||
(scope.model.open && target.has_attribute("aria-activedescendant")) =>
Some(ComboboxChoose(-1))
"Escape" if scope.model.open => Some(ComboboxClose)
_ => None
}
if message is Some(message) {
event.prevent_default()
(scope.emit)(message)
} else {
@cmd.none
}
}
///|
#cfg(target="js")
fn combobox_bind_root_events(
attrs : @html.Attrs,
scope : ComboboxScope,
) -> Unit {
ignore(attrs.on_mousedown(combobox_root_mousedown))
ignore(attrs.on_click(event => combobox_root_click(scope, event)))
ignore(attrs.on_keydown(event => combobox_root_keydown(scope, event)))
}
///|
#cfg(not(target="js"))
fn combobox_bind_root_events(
attrs : @html.Attrs,
scope : ComboboxScope,
) -> Unit {
ignore((attrs, scope))
}
///|
#cfg(target="js")
fn combobox_bind_events_cmd(
id : String,
emit : @cmd.Emit[ComboboxMsg],
) -> @cmd.Cmd {
@cmd.custom_cmd(kind=@cmd.AfterRender, scheduler => {
ui_after_mount(
id + "-root",
() => bind_combobox_events(id, _ => scheduler.add(emit(ComboboxDismiss))),
purpose="combobox-focusout",
)
})
}
///|
fn combobox_root_attrs(
scope : ComboboxScope,
attrs : @html.Attrs?,
) -> @html.Attrs {
let root_attrs = popup_state_attrs(attrs, "combobox", scope.model.open)
.data_set("multiple", ui_bool(scope.multiple))
.data_set("active-index", "\{scope.model.active}")
if scope.disabled {
ignore(root_attrs.data_set("disabled", "").aria_disabled("true"))
}
if scope.invalid {
ignore(root_attrs.data_set("invalid", "").aria_invalid("true"))
}
combobox_bind_root_events(root_attrs, scope)
root_attrs
}
///|
pub fn combobox_value(
scope~ : ComboboxScope,
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
prefix? : @html.Html,
) -> @html.Html {
let empty = scope.model.values.length() == 0
let prefix_html = prefix.unwrap_or(@html.nothing)
@html.span(
id?,
class?,
title?,
attrs=ui_attrs(attrs)
.data_set("slot", "combobox-value")
.data_set("placeholder", ui_bool(empty)),
style=ui_styles(
[
"display:flex;min-width:0;flex:1;align-items:center;gap:0.375rem;overflow:hidden;text-overflow:ellipsis",
if empty {
"color:var(--rui-muted-foreground,oklch(0.556 0 0))"
} else {
""
},
],
style,
),
[
prefix_html,
@html.span(
style=["min-width:0;overflow:hidden;text-overflow:ellipsis"],
combobox_selected_label(scope),
),
],
)
}
///|
pub fn combobox_trigger(
scope~ : ComboboxScope,
aria_label? : String,
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
) -> @html.Html {
let resolved_id = id.unwrap_or("\{scope.id}-trigger")
if !scope.has_input.val {
scope.anchor_id.val = resolved_id
}
let trigger_attrs = popup_state_attrs(
attrs,
"combobox-trigger",
scope.model.open,
)
.aria_haspopup("listbox")
.aria_expanded(ui_bool(scope.model.open))
if scope.has_input.val {
ignore(trigger_attrs.tabindex(-1))
} else {
ignore(trigger_attrs.role("combobox"))
}
if scope.model.open {
ignore(trigger_attrs.aria_controls(scope.content_id.val))
}
if !scope.has_input.val && scope.model.open && scope.model.active >= 0 {
ignore(
trigger_attrs.aria_activedescendant(
combobox_item_id(scope, scope.model.active),
),
)
}
if aria_label is Some(label) {
ignore(trigger_attrs.aria_label(label))
}
if !scope.disabled {
ignore(trigger_attrs.on_click(_ => (scope.emit)(ComboboxToggle)))
}
@html.button(
id=resolved_id,
class?,
title?,
type_="button",
disabled=scope.disabled,
attrs=trigger_attrs,
style=ui_styles(
[
UiBoxSizing,
UiFontSans,
UiTransition,
SelectTriggerStyle,
input_invalid_style(scope.invalid),
ui_disabled_style(scope.disabled),
popup_anchor_style(resolved_id),
],
style,
),
[combobox_value(scope~), combobox_trigger_icon()],
)
}
///|
#cfg(target="js")
fn combobox_input_keydown(
scope : ComboboxScope,
event : @dom.KeyboardEvent,
) -> @cmd.Cmd {
let key = event.key()
if scope.multiple &&
!scope.disabled &&
scope.model.query == "" &&
scope.model.values.length() > 0 &&
(key == "Backspace" || key == "Delete") &&
!event.alt_key() &&
!event.ctrl_key() &&
!event.meta_key() &&
!event.is_composing() {
event.prevent_default()
(scope.emit)(
ComboboxRemove(scope.model.values[scope.model.values.length() - 1]),
)
} else {
@cmd.none
}
}
///|
#cfg(target="js")
fn combobox_input_keyboard_attrs(
scope : ComboboxScope,
attrs : @html.Attrs,
) -> @html.Attrs {
attrs.on_keydown(event => combobox_input_keydown(scope, event))
}
///|
#cfg(not(target="js"))
fn combobox_input_keyboard_attrs(
scope : ComboboxScope,
attrs : @html.Attrs,
) -> @html.Attrs {
ignore(scope)
attrs
}
///|
fn combobox_input_surface(
scope : ComboboxScope,
slot : String,
layout_style : String,
aria_label : String,
auto_complete : @html.AutoComplete,
id : String?,
class : String?,
title : String?,
attrs : @html.Attrs?,
style : Array[String],
) -> @html.Html {
let resolved_id = id.unwrap_or("\{scope.id}-input")
scope.has_input.val = true
scope.anchor_id.val = resolved_id
let display_value = scope.model.query
let input_attrs = combobox_input_keyboard_attrs(
scope,
popup_state_attrs(attrs, slot, scope.model.open)
.role("combobox")
.aria_label(aria_label)
.aria_autocomplete("list")
.aria_expanded(ui_bool(scope.model.open))
.aria_haspopup("listbox")
.aria_invalid(ui_bool(scope.invalid))
.aria_required(ui_bool(scope.required)),
)
if scope.model.open {
ignore(input_attrs.aria_controls(scope.content_id.val))
}
if scope.model.open && scope.model.active >= 0 {
ignore(
input_attrs.aria_activedescendant(
combobox_item_id(scope, scope.model.active),
),
)
}
if scope.disabled {
ignore(input_attrs.disabled(true))
}
@html.input(
input_type=@html.Text,
value=display_value,
placeholder=scope.placeholder,
auto_complete~,
id=resolved_id,
class?,
title?,
on_input=scope.emit.map(value => ComboboxSetQuery(value)),
attrs=input_attrs,
style=ui_styles(
[
UiBoxSizing,
UiFontSans,
layout_style,
ComboboxInputBaseStyle,
ui_disabled_style(scope.disabled),
popup_anchor_style(resolved_id),
],
style,
),
)
}
///|
pub fn combobox_input(
scope~ : ComboboxScope,
aria_label? : String = "Search options",
auto_complete? : @html.AutoComplete = @html.Off,
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
) -> @html.Html {
combobox_input_surface(
scope,
"combobox-input",
ComboboxInputLayoutStyle,
aria_label,
auto_complete,
id,
class,
title,
attrs,
style,
)
}
///|
/// Render the query input inside a multi-select chips surface.
///
/// It shares the regular combobox input's value, ARIA active-descendant, query
/// updates, focus opening, and keyboard navigation while exposing shadcn's
/// singular `combobox-chip-input` data slot.
pub fn combobox_chips_input(
scope~ : ComboboxScope,
aria_label? : String = "Search options",
auto_complete? : @html.AutoComplete = @html.Off,
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
) -> @html.Html {
combobox_input_surface(
scope,
"combobox-chip-input",
ComboboxChipsInputLayoutStyle,
aria_label,
auto_complete,
id,
class,
title,
attrs,
style,
)
}
///|
pub fn combobox_clear(
scope~ : ComboboxScope,
label? : String = "Clear selection",
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
) -> @html.Html {
let hidden = scope.model.values.length() == 0 && scope.model.query == ""
let clear_attrs = ui_attrs(attrs)
.aria_label(label)
.data_set("slot", "combobox-clear")
.data_set("variant", button_variant_value(Ghost))
.data_set("size", button_size_value(IconXs))
.tabindex(-1)
if !scope.disabled {
ignore(clear_attrs.on_click(_ => (scope.emit)(ComboboxClear)))
}
@html.button(
id?,
class?,
title?,
hidden~,
type_="button",
disabled=scope.disabled,
attrs=clear_attrs,
style=ui_styles(
[
UiBoxSizing,
UiFontSans,
UiTextRendering,
UiTransition,
ButtonBaseStyle,
button_variant_style(Ghost),
button_size_style(IconXs),
"flex:none;--rui-button-base-fg:var(--rui-muted-foreground,oklch(0.556 0 0));font-size:1rem",
ui_disabled_style(scope.disabled),
],
style,
),
ui_x_icon(),
)
}
///|
fn combobox_default_trigger(scope : ComboboxScope) -> @html.Html {
let trigger_attrs = popup_state_attrs(
None,
"combobox-trigger",
scope.model.open,
)
.data_set("variant", button_variant_value(Ghost))
.data_set("size", button_size_value(IconXs))
.aria_label("Toggle options")
.aria_haspopup("listbox")
.aria_expanded(ui_bool(scope.model.open))
.tabindex(-1)
if scope.model.open {
ignore(trigger_attrs.aria_controls(scope.content_id.val))
}
if !scope.disabled {
ignore(trigger_attrs.on_click(_ => (scope.emit)(ComboboxToggle)))
}
@html.button(
type_="button",
disabled=scope.disabled,
attrs=trigger_attrs,
style=[
UiBoxSizing,
UiFontSans,
UiTextRendering,
UiTransition,
ButtonBaseStyle,
button_variant_style(Ghost),
button_size_style(IconXs),
"flex:none;--rui-button-base-fg:var(--rui-muted-foreground,oklch(0.556 0 0));font-size:1rem",
ui_disabled_style(scope.disabled),
],
combobox_trigger_icon(),
)
}
///|
pub fn[C : @html.IsChildren] combobox_list(
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
@html.div(
id?,
class?,
title?,
attrs=ui_attrs(attrs).role("presentation").data_set("slot", "combobox-list"),
style=ui_styles(["min-height:0;overflow-y:auto;padding:0.25rem"], style),
children,
)
}
///|
pub fn[C : @html.IsChildren] combobox_item(
scope~ : ComboboxScope,
value~ : String,
disabled? : Bool,
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
let index = combobox_option_index(scope.options, value)
let option_disabled = if disabled is Some(disabled) {
disabled
} else if index >= 0 {
scope.options[index].disabled
} else {
false
}
let selected = combobox_contains(scope.model.values, value)
let active = scope.model.open && index >= 0 && scope.model.active == index
let item_attrs = ui_attrs(attrs)
.role("option")
.aria_selected(ui_bool(selected))
.aria_disabled(ui_bool(option_disabled))
.data_set("slot", "combobox-item")
.data_set("value", value)
.data_set("state", if selected { "checked" } else { "unchecked" })
.data_set("highlighted", ui_bool(active))
.tabindex(-1)
if option_disabled {
ignore(item_attrs.data_set("disabled", ""))
} else if index >= 0 {
ignore(item_attrs.on_click(_ => (scope.emit)(ComboboxChoose(index))))
ignore(
item_attrs.on_mouseenter(_ => (scope.emit)(ComboboxHighlight(index))),
)
}
@html.button(
id=id.unwrap_or(
if index >= 0 {
combobox_item_id(scope, index)
} else {
""
},
),
class?,
title?,
type_="button",
disabled=option_disabled,
attrs=item_attrs,
style=ui_styles(
[
UiBoxSizing,
UiFontSans,
UiTransition,
ComboboxItemStyle,
if active {
"--rui-menu-item-base-bg:var(--rui-accent,oklch(0.97 0 0));--rui-menu-item-base-fg:var(--rui-accent-foreground,oklch(0.205 0 0))"
} else {
""
},
ui_disabled_visual_style(option_disabled),
],
style,
),
[
@html.span(
style=["min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis"],
children,
),
if selected {
@html.span(
attrs=@html.Attrs::build()
.aria_hidden("true")
.data_set("slot", "combobox-item-indicator"),
style=[
"position:absolute;inset-inline-end:0.5rem;display:inline-flex;width:1rem;height:1rem;align-items:center;justify-content:center",
],
ui_check_icon(),
)
} else {
@html.nothing
},
],
)
}
///|
pub fn[C : @html.IsChildren] combobox_group(
label_id? : String,
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
let group_attrs = ui_attrs(attrs)
.role("group")
.data_set("slot", "combobox-group")
if label_id is Some(label_id) {
ignore(group_attrs.aria_labelledby(label_id))
}
@html.div(
id?,
class?,
title?,
attrs=group_attrs,
style=ui_styles(["padding:0.125rem 0"], style),
children,
)
}
///|
pub fn[C : @html.IsChildren] combobox_label(
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
@html.div(
id?,
class?,
title?,
attrs=ui_attrs(attrs).data_set("slot", "combobox-label"),
style=ui_styles(
[
"padding:0.375rem 0.5rem;color:var(--rui-muted-foreground,oklch(0.556 0 0));font-size:0.75rem;line-height:1rem;font-weight:500",
],
style,
),
children,
)
}
///|
pub fn[C : @html.IsChildren] combobox_collection(
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
@html.div(
id?,
class?,
title?,
attrs=ui_attrs(attrs).data_set("slot", "combobox-collection"),
style=ui_styles(["display:contents"], style),
children,
)
}
///|
pub fn[C : @html.IsChildren] combobox_empty(
visible? : Bool = true,
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
@html.div(
id?,
class?,
title?,
hidden=!visible,
attrs=ui_attrs(attrs).role("status").data_set("slot", "combobox-empty"),
style=ui_styles(
[
"padding:1.5rem 0.75rem;color:var(--rui-muted-foreground,oklch(0.556 0 0));font-size:0.875rem;text-align:center",
],
style,
),
children,
)
}
///|
pub fn combobox_separator(
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
) -> @html.Html {
@html.div(
id?,
class?,
title?,
attrs=ui_attrs(attrs)
.role("separator")
.data_set("slot", "combobox-separator"),
style=ui_styles(
[
"height:1px;margin:0.25rem -0.25rem;background:var(--rui-border,oklch(0.922 0 0));pointer-events:none",
],
style,
),
"",
)
}
///|
pub fn[C : @html.IsChildren] combobox_chips(
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
@html.div(
id?,
class?,
title?,
attrs=ui_attrs(attrs).data_set("slot", "combobox-chips"),
style=ui_styles(
[UiBoxSizing, UiFontSans, UiTransition, ComboboxChipsStyle],
style,
),
children,
)
}
///|
pub fn[C : @html.IsChildren] combobox_chip(
scope~ : ComboboxScope,
value~ : String,
removable? : Bool = true,
remove_label? : String = "Remove",
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
@html.span(
id?,
class?,
title?,
attrs=ui_attrs(attrs)
.data_set("slot", "combobox-chip")
.data_set("value", value),
style=ui_styles(
[
"display:inline-flex;max-width:100%;height:1.5rem;align-items:center;gap:0.25rem;border:1px solid var(--rui-border,oklch(0.922 0 0));border-radius:calc(var(--rui-radius,0.625rem) - 0.25rem);background:var(--rui-secondary,oklch(0.97 0 0));padding:0 0.375rem;color:var(--rui-secondary-foreground,oklch(0.205 0 0));font-size:0.75rem;line-height:1rem",
],
style,
),
[
@html.span(
style=["min-width:0;overflow:hidden;text-overflow:ellipsis"],
children,
),
if removable {
@html.button(
type_="button",
disabled=scope.disabled,
attrs=@html.Attrs::build()
.aria_label("\{remove_label}: \{value}")
.data_set("slot", "combobox-chip-remove")
.data_set("variant", button_variant_value(Ghost))
.data_set("size", button_size_value(IconXs))
.on_click(_ => (scope.emit)(ComboboxRemove(value))),
style=ui_styles(
[
UiBoxSizing,
UiFontSans,
UiTransition,
ButtonBaseStyle,
button_variant_style(Ghost),
button_size_style(IconXs),
"margin-inline-start:-0.25rem;--rui-button-base-fg:inherit;opacity:var(--rui-chip-remove-opacity,0.5);font-size:0.875rem",
ui_disabled_style(scope.disabled),
],
[],
),
ui_x_icon(),
)
} else {
@html.nothing
},
],
)
}
///|
pub fn[C : @html.IsChildren] combobox_content(
scope~ : ComboboxScope,
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
children : C,
) -> @html.Html {
let resolved_id = id.unwrap_or("\{scope.id}-content")
scope.content_id.val = resolved_id
let content_attrs = popup_state_attrs(
attrs,
"combobox-content",
scope.model.open,
)
.popover("auto")
.role("listbox")
.aria_multiselectable(ui_bool(scope.multiple))
.aria_required(ui_bool(scope.required))
.aria_invalid(ui_bool(scope.invalid))
.data_set("trigger-id", scope.anchor_id.val)
.data_set("requested-side", popup_side_value(scope.side))
.data_set("requested-align", popup_align_value(scope.align))
.data_set("side", popup_side_value(scope.side))
.data_set("align", popup_align_value(scope.align))
.data_set("side-offset", "\{scope.side_offset}")
.data_set("align-offset", "\{scope.align_offset}")
@html.div(
id=resolved_id,
class?,
title?,
attrs=content_attrs,
style=ui_styles(
[
UiBoxSizing,
UiFontSans,
UiTextRendering,
ComboboxContentStyle,
PopupTransition100Style,
popup_floating_anchor_style(
scope.anchor_id.val,
scope.side,
scope.align,
scope.side_offset,
scope.align_offset,
),
popup_state_style(scope.model.open),
],
style,
),
children,
)
}
///|
fn combobox_default_items(scope : ComboboxScope) -> @html.Html {
let items : Array[@html.Html] = []
let filter_query = combobox_filter_query(
scope.options,
scope.model.values,
scope.multiple,
scope.model.query,
)
for option in scope.options {
if combobox_matches(option, filter_query) {
items.push(combobox_item(scope~, value=option.value, option.label))
}
}
combobox_list([
combobox_empty(visible=items.length() == 0, scope.empty_text),
@html.fragment(items),
])
}
///|
fn combobox_default_view(scope : ComboboxScope) -> @html.Html {
let chips : Array[@html.Html] = []
if scope.multiple {
for value in scope.model.values {
let index = combobox_option_index(scope.options, value)
if index >= 0 {
chips.push(combobox_chip(scope~, value~, scope.options[index].label))
}
}
}
let action = if scope.model.values.length() > 0 || scope.model.query != "" {
combobox_clear(scope~)
} else {
combobox_default_trigger(scope)
}
let control_children : Array[@html.Html] = if scope.multiple {
[
combobox_chips(style=[input_invalid_style(scope.invalid)], [
@html.fragment(chips),
combobox_chips_input(scope~),
action,
]),
]
} else {
[combobox_input(scope~), action]
}
let control_id = "\{scope.id}-control"
scope.anchor_id.val = control_id
let control = @html.div(
id=control_id,
attrs=@html.Attrs::build().data_set("slot", "combobox-control"),
style=[
UiBoxSizing,
if scope.multiple {
ComboboxControlLayoutStyle
} else {
ComboboxControlStyle
},
if scope.multiple {
""
} else {
input_invalid_style(scope.invalid)
},
popup_anchor_style(control_id),
],
control_children,
)
@html.fragment([
control,
combobox_content(scope~, combobox_default_items(scope)),
])
}
///|
fn combobox_hidden_inputs(scope : ComboboxScope) -> @html.Html {
if scope.name is Some(name) {
let controls : Array[@html.Html] = []
if scope.model.values.length() == 0 {
controls.push(
@html.input(
input_type=@html.Hidden,
name~,
value="",
attrs=@html.Attrs::build().disabled(scope.disabled),
),
)
} else {
for value in scope.model.values {
controls.push(
@html.input(
input_type=@html.Hidden,
name~,
value~,
attrs=@html.Attrs::build().disabled(scope.disabled),
),
)
}
}
@html.fragment(controls)
} else {
@html.nothing
}
}
///|
fn combobox_render(
scope : ComboboxScope,
class : String?,
title : String?,
attrs : @html.Attrs?,
style : Array[String],
render : ((ComboboxScope) -> @html.Html)?,
) -> @html.Html {
@html.div(
id="\{scope.id}-root",
class?,
title?,
attrs=combobox_root_attrs(scope, attrs),
style=ui_styles(
[
UiBoxSizing,
UiFontSans,
UiTextRendering,
ComboboxRootStyle,
ui_disabled_visual_style(scope.disabled),
],
style,
),
[
combobox_hidden_inputs(scope),
if render is Some(render) {
// Discover a custom popup id before rendering an earlier input or
// trigger so their aria-controls references the actual listbox.
ignore(render(scope))
render(scope)
} else {
combobox_default_view(scope)
},
],
)
}
///|
#cfg(target="js")
pub fn combobox(
id~ : String,
options~ : Array[ComboboxOption],
default_value? : String,
default_values? : Array[String] = [],
multiple? : Bool = false,
default_open? : Bool = false,
placeholder? : String = "Select an option",
empty_text? : String = "No results found.",
disabled? : Bool = false,
invalid? : Bool = false,
required? : Bool = false,
name? : String,
side? : PopupSide = Bottom,
align? : PopupAlign = Start,
side_offset? : Int = 4,
align_offset? : Int = 0,
on_value_change? : @cmd.Emit[String],
on_values_change? : @cmd.Emit[Array[String]],
on_query_change? : @cmd.Emit[String],
on_open_change? : @cmd.Emit[Bool],
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
render? : (ComboboxScope) -> @html.Html,
) -> @rabbita.Val[@html.Html] {
let requested = default_values.copy()
if default_value is Some(value) && !combobox_contains(requested, value) {
requested.push(value)
}
let values = combobox_normalize_values(options, requested, multiple)
let initial_query = combobox_selected_input_value(options, values, multiple)
let initial : ComboboxModel = {
open: default_open && !disabled,
query: initial_query,
values,
active: -1,
}
let (model, emit) = @rabbita.create_state_with_init(
init=emit => {
(
initial,
@cmd.batch([
ui_listbox_bind_sync_cmd(
id,
"combobox-content",
emit.map(value => ComboboxNativeChanged(value)),
),
combobox_bind_events_cmd(id, emit),
if initial.open {
combobox_popup_cmd(id, Some(true), false)
} else {
@cmd.none
},
]),
)
},
update=(_, message, current) => {
match message {
ComboboxOpen =>
if disabled || current.open {
(current, @cmd.none)
} else {
(
{ ..current, open: true, active: -1 },
@cmd.batch([
combobox_popup_cmd(id, Some(true), false),
combobox_notify_open(on_open_change, true),
]),
)
}
ComboboxToggle =>
if disabled {
(current, @cmd.none)
} else {
let open = !current.open
let query = if open {
current.query
} else {
combobox_selected_input_value(options, current.values, multiple)
}
(
{ ..current, open, query, active: -1 },
@cmd.batch([
combobox_popup_cmd(id, Some(open), true),
if query != current.query {
combobox_notify_query(on_query_change, query)
} else {
@cmd.none
},
combobox_notify_open(on_open_change, open),
]),
)
}
ComboboxNativeChanged(open) =>
if current.open == open {
(current, @cmd.none)
} else if open {
(
{ ..current, open: true, active: -1 },
combobox_notify_open(on_open_change, true),
)
} else {
let query = combobox_selected_input_value(
options,
current.values,
multiple,
)
(
{ ..current, open: false, query, active: -1 },
@cmd.batch([
combobox_notify_open(on_open_change, false),
if query != current.query {
combobox_notify_query(on_query_change, query)
} else {
@cmd.none
},
]),
)
}
ComboboxSetQuery(query) => {
let clearing_selection = !multiple &&
query == "" &&
current.values.length() > 0
let values = if clearing_selection { [] } else { current.values }
(
{ open: true, query, values, active: -1 },
@cmd.batch([
combobox_popup_cmd(id, Some(true), false),
if current.open {
@cmd.none
} else {
combobox_notify_open(on_open_change, true)
},
if clearing_selection {
combobox_notify_values(
on_value_change, on_values_change, values,
)
} else {
@cmd.none
},
combobox_notify_query(on_query_change, query),
]),
)
}
ComboboxMove(direction) => {
let filter_query = combobox_filter_query(
options,
current.values,
multiple,
current.query,
)
let active = combobox_next_match(
options,
filter_query,
current.active,
direction,
)
(
{ ..current, open: true, active },
@cmd.batch([
combobox_popup_cmd(id, Some(true), false),
if current.open {
@cmd.none
} else {
combobox_notify_open(on_open_change, true)
},
]),
)
}
ComboboxFirst => {
let filter_query = combobox_filter_query(
options,
current.values,
multiple,
current.query,
)
let active = combobox_first_match(options, filter_query, false)
({ ..current, active, }, @cmd.none)
}
ComboboxLast => {
let filter_query = combobox_filter_query(
options,
current.values,
multiple,
current.query,
)
let active = combobox_first_match(options, filter_query, true)
({ ..current, active, }, @cmd.none)
}
ComboboxHighlight(index) => ({ ..current, active: index }, @cmd.none)
ComboboxChoose(requested_index) => {
let index = if requested_index < 0 {
current.active
} else {
requested_index
}
if index < 0 ||
index >= options.length() ||
options[index].disabled ||
!combobox_matches(
options[index],
combobox_filter_query(
options,
current.values,
multiple,
current.query,
),
) {
(current, @cmd.none)
} else {
let values = combobox_toggle_value(
current.values,
options[index].value,
multiple,
)
let open = multiple
let query = combobox_selected_input_value(options, values, multiple)
(
{ values, open, query, active: if multiple { index } else { -1 } },
@cmd.batch([
combobox_notify_values(
on_value_change, on_values_change, values,
),
combobox_notify_query(on_query_change, query),
combobox_popup_cmd(
id,
if multiple {
None
} else {
Some(false)
},
true,
),
if multiple {
@cmd.none
} else {
combobox_notify_open(on_open_change, false)
},
]),
)
}
}
ComboboxRemove(value) => {
let values = combobox_remove_value(current.values, value)
(
{ ..current, values, },
combobox_notify_values(on_value_change, on_values_change, values),
)
}
ComboboxClear =>
(
{ ..current, values: [], query: "", active: -1 },
@cmd.batch([
combobox_notify_values(on_value_change, on_values_change, []),
combobox_notify_query(on_query_change, ""),
combobox_popup_cmd(id, None, true),
]),
)
ComboboxClose =>
if current.open {
let query = combobox_selected_input_value(
options,
current.values,
multiple,
)
(
{ ..current, open: false, query, active: -1 },
@cmd.batch([
combobox_popup_cmd(id, Some(false), false),
combobox_notify_open(on_open_change, false),
if query != current.query {
combobox_notify_query(on_query_change, query)
} else {
@cmd.none
},
]),
)
} else {
(current, @cmd.none)
}
ComboboxDismiss =>
if current.open {
let query = combobox_selected_input_value(
options,
current.values,
multiple,
)
(
{ ..current, open: false, query, active: -1 },
@cmd.batch([
combobox_popup_cmd(id, Some(false), false),
combobox_notify_open(on_open_change, false),
if query != current.query {
combobox_notify_query(on_query_change, query)
} else {
@cmd.none
},
]),
)
} else {
(current, @cmd.none)
}
}
},
)
model.view(model => {
combobox_render(
{
id,
options,
model,
multiple,
placeholder,
empty_text,
disabled,
invalid,
required,
name,
side,
align,
side_offset,
align_offset,
emit,
content_id: Ref("\{id}-content"),
anchor_id: Ref("\{id}-input"),
has_input: Ref(false),
},
class,
title,
attrs,
style,
render,
)
})
}
///|
#cfg(not(target="js"))
pub fn combobox(
id~ : String,
options~ : Array[ComboboxOption],
default_value? : String,
default_values? : Array[String] = [],
multiple? : Bool = false,
default_open? : Bool = false,
placeholder? : String = "Select an option",
empty_text? : String = "No results found.",
disabled? : Bool = false,
invalid? : Bool = false,
required? : Bool = false,
name? : String,
side? : PopupSide = Bottom,
align? : PopupAlign = Start,
side_offset? : Int = 4,
align_offset? : Int = 0,
on_value_change? : @cmd.Emit[String],
on_values_change? : @cmd.Emit[Array[String]],
on_query_change? : @cmd.Emit[String],
on_open_change? : @cmd.Emit[Bool],
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
render? : (ComboboxScope) -> @html.Html,
) -> @rabbita.Val[@html.Html] {
ignore((on_value_change, on_values_change, on_query_change, on_open_change))
let requested = default_values.copy()
if default_value is Some(value) && !combobox_contains(requested, value) {
requested.push(value)
}
let model : ComboboxModel = {
open: default_open && !disabled,
query: combobox_selected_input_value(
options,
combobox_normalize_values(options, requested, multiple),
multiple,
),
values: combobox_normalize_values(options, requested, multiple),
active: -1,
}
@rabbita.Val::constant(
combobox_render(
{
id,
options,
model,
multiple,
placeholder,
empty_text,
disabled,
invalid,
required,
name,
side,
align,
side_offset,
align_offset,
emit: @cmd.Emit(_ => @cmd.none),
content_id: Ref("\{id}-content"),
anchor_id: Ref("\{id}-input"),
has_input: Ref(false),
},
class,
title,
attrs,
style,
render,
),
)
}