From ae82e86f7c019b7983d2cef503795dba1f8070bb Mon Sep 17 00:00:00 2001 From: Saren Date: Tue, 26 May 2026 19:04:31 -0700 Subject: [PATCH 1/8] feat(primitives): expose virtualizer primitives --- primitives/src/virtual/mod.rs | 10 +++++++--- primitives/src/virtual/types.rs | 22 ++++++++++++++-------- primitives/src/virtual/virtualizer.rs | 23 +++++++++++++++++++++++ primitives/src/virtual_list.rs | 13 +------------ 4 files changed, 45 insertions(+), 23 deletions(-) diff --git a/primitives/src/virtual/mod.rs b/primitives/src/virtual/mod.rs index a0be72dca..b0c207b62 100644 --- a/primitives/src/virtual/mod.rs +++ b/primitives/src/virtual/mod.rs @@ -1,16 +1,20 @@ -//! Virtual list implementation using Dioxus Store for fine-grained reactivity. +//! Low-level virtualizer primitives using Dioxus Store for fine-grained reactivity. //! //! This module provides the core algorithms needed for efficient list virtualization: //! //! - Computing item positions from measured or estimated sizes //! - Calculating the visible range using binary search //! - Handling scroll position corrections when items resize +//! +//! These APIs intentionally do not render any accessibility roles, keyboard behavior, focus +//! management, or scroll container DOM. Higher-level components such as virtualized listboxes must +//! provide those semantics themselves. -pub(crate) mod types; +pub mod types; mod utils; mod virtualizer; -pub(crate) use virtualizer::{ +pub use virtualizer::{ compute_measurements, get_total_size, get_virtual_items, resize_item, set_scroll_offset, set_viewport_size, VirtualizerState, VirtualizerStateStoreExt, }; diff --git a/primitives/src/virtual/types.rs b/primitives/src/virtual/types.rs index 52e263123..444b80351 100644 --- a/primitives/src/virtual/types.rs +++ b/primitives/src/virtual/types.rs @@ -1,11 +1,11 @@ //! Core types for the virtual list implementation. /// A unique key for identifying items in the virtualizer. -pub(crate) type Key = usize; +pub type Key = usize; /// A single virtualized item with computed position. #[derive(Debug, Clone, PartialEq)] -pub(crate) struct VirtualItem { +pub struct VirtualItem { key: Key, index: usize, start: u32, @@ -13,7 +13,8 @@ pub(crate) struct VirtualItem { } impl VirtualItem { - pub(crate) fn new(key: Key, index: usize, start: u32, size: u32) -> Self { + /// Create a new virtual item measurement. + pub fn new(key: Key, index: usize, start: u32, size: u32) -> Self { Self { key, index, @@ -22,23 +23,28 @@ impl VirtualItem { } } - pub(crate) fn key(&self) -> Key { + /// Return the stable key used for size caching. + pub fn key(&self) -> Key { self.key } - pub(crate) fn index(&self) -> usize { + /// Return the absolute item index. + pub fn index(&self) -> usize { self.index } - pub(crate) fn start(&self) -> u32 { + /// Return the item start offset in pixels. + pub fn start(&self) -> u32 { self.start } - pub(crate) fn end(&self) -> u32 { + /// Return the item end offset in pixels. + pub fn end(&self) -> u32 { self.start + self.size } - pub(crate) fn size(&self) -> u32 { + /// Return the item size in pixels. + pub fn size(&self) -> u32 { self.size } } diff --git a/primitives/src/virtual/virtualizer.rs b/primitives/src/virtual/virtualizer.rs index 89bc31be1..7ea4b6c22 100644 --- a/primitives/src/virtual/virtualizer.rs +++ b/primitives/src/virtual/virtualizer.rs @@ -1,4 +1,5 @@ //! Core Virtualizer implementation using Dioxus Store for fine-grained reactivity. +#![allow(missing_docs)] use std::collections::HashMap; use std::ops::Range; @@ -37,6 +38,28 @@ pub struct VirtualizerState { pub deferred_adjustments: i32, } +impl VirtualizerState { + /// Create an empty virtualizer state. + pub fn new() -> Self { + Self { + scroll_offset: 0, + viewport_size: 0, + is_scrolling: false, + item_size_cache: HashMap::new(), + scroll_adjustments: 0, + stable_total_size: None, + stable_measurement_count: None, + deferred_adjustments: 0, + } + } +} + +impl Default for VirtualizerState { + fn default() -> Self { + Self::new() + } +} + // --------------------------------------------------------------------------- // Public API – free functions operating on Store // --------------------------------------------------------------------------- diff --git a/primitives/src/virtual_list.rs b/primitives/src/virtual_list.rs index 3a613629b..de0065988 100644 --- a/primitives/src/virtual_list.rs +++ b/primitives/src/virtual_list.rs @@ -1,7 +1,5 @@ //! Defines the [`VirtualList`] component for rendering large lists with virtualization. -use std::collections::HashMap; - use dioxus::prelude::*; use serde::Deserialize; @@ -84,16 +82,7 @@ pub fn VirtualList(props: VirtualListProps) -> Element { let container_id = crate::use_unique_id(); // Create the Store — only holds mutable shared state - let state: Store = use_store(|| VirtualizerState { - scroll_offset: 0, - viewport_size: 0, - is_scrolling: false, - item_size_cache: HashMap::new(), - scroll_adjustments: 0, - stable_total_size: None, - stable_measurement_count: None, - deferred_adjustments: 0, - }); + let state: Store = use_store(VirtualizerState::new); // Measurements as a memo — recomputes when count or item_size_cache change. // Read (not peeked) by the render body so the component re-renders when the From 9307d0f61bf51bd9a33c11f4b0ca869563f96b95 Mon Sep 17 00:00:00 2001 From: Saren Date: Tue, 26 May 2026 19:04:31 -0700 Subject: [PATCH 2/8] feat(primitives): add combobox hook anatomy --- .../src/combobox/components/combobox.rs | 28 +- .../src/combobox/components/high_level.rs | 433 +++++++++++++ primitives/src/combobox/components/input.rs | 153 +---- primitives/src/combobox/components/list.rs | 30 +- primitives/src/combobox/components/mod.rs | 18 +- primitives/src/combobox/components/option.rs | 25 +- primitives/src/combobox/components/target.rs | 361 +++++++++++ .../src/combobox/components/virtualized.rs | 284 +++++++++ primitives/src/combobox/context.rs | 77 ++- primitives/src/combobox/hook.rs | 582 ++++++++++++++++++ primitives/src/combobox/mod.rs | 23 +- primitives/src/disclosure.rs | 90 +++ primitives/src/lib.rs | 1 + primitives/src/listbox.rs | 12 +- primitives/src/selectable.rs | 55 +- 15 files changed, 2018 insertions(+), 154 deletions(-) create mode 100644 primitives/src/combobox/components/high_level.rs create mode 100644 primitives/src/combobox/components/target.rs create mode 100644 primitives/src/combobox/components/virtualized.rs create mode 100644 primitives/src/combobox/hook.rs create mode 100644 primitives/src/disclosure.rs diff --git a/primitives/src/combobox/components/combobox.rs b/primitives/src/combobox/components/combobox.rs index ba97c5b6f..4ddac268d 100644 --- a/primitives/src/combobox/components/combobox.rs +++ b/primitives/src/combobox/components/combobox.rs @@ -3,9 +3,11 @@ use dioxus::prelude::*; use super::super::context::{default_combobox_filter, ComboboxContext}; +use super::super::hook::{use_combobox, UseComboboxOptions}; use crate::{ selectable::{ - use_selectable_root, use_single_selectable_value, RcPartialEqValue, SelectionMode, + use_selectable_root_with_state, use_single_selectable_value, RcPartialEqValue, + SelectionMode, }, use_controlled, Controlled, }; @@ -70,34 +72,43 @@ pub struct ComboboxProps { pub children: Element, } -fn use_combobox_root( +pub(super) fn use_combobox_root( values: Memo>, set_value: Callback, + selection_mode: SelectionMode, disabled: ReadSignal, roving_loop: ReadSignal, open: Controlled, query: Controlled, filter: Callback<(String, String), bool>, ) -> Memo { - let selectable = use_selectable_root( + let store = use_combobox(UseComboboxOptions { + opened: open.value, + default_opened: open.default, + on_opened_change: open.on_change, + loop_navigation: roving_loop, + ..Default::default() + }); + let store_open = use_memo(move || store.dropdown_opened()); + let selectable = use_selectable_root_with_state( values, set_value, - SelectionMode::Single, + selection_mode, disabled, roving_loop, - open, + store_open, + Callback::new(move |_| {}), ); let (query, set_query) = use_controlled(query.value, query.default.cloned(), query.on_change); - let open = selectable.open; - use_context_provider(|| ComboboxContext { selectable, + store, query, set_query, filter, }); - open + use_memo(move || store.dropdown_opened()) } /// A single-select autocomplete input with a filterable popup list. @@ -113,6 +124,7 @@ pub fn Combobox(props: ComboboxProps) -> Elem let open = use_combobox_root( selected, set_value, + SelectionMode::Single, props.disabled, props.roving_loop, Controlled { diff --git a/primitives/src/combobox/components/high_level.rs b/primitives/src/combobox/components/high_level.rs new file mode 100644 index 000000000..34d2843e5 --- /dev/null +++ b/primitives/src/combobox/components/high_level.rs @@ -0,0 +1,433 @@ +//! Higher-level combobox-based input components. + +use core::panic; + +use dioxus::prelude::*; + +use super::combobox::use_combobox_root; +use super::{ + Combobox, ComboboxDropdownTarget, ComboboxInput, ComboboxOptions, ComboboxSearch, + ComboboxTarget, +}; +use crate::{ + combobox::context::default_combobox_filter, + selectable::{RcPartialEqValue, SelectionMode}, + use_controlled, Controlled, +}; + +/// Props for [`Autocomplete`]. +#[derive(Props, Clone, PartialEq)] +pub struct AutocompleteProps { + /// The controlled input value. + #[props(default)] + pub value: Option>>, + + /// The initial uncontrolled input value. + #[props(default)] + pub default_value: Option, + + /// Callback fired when the input value changes. + #[props(default)] + pub on_value_change: Callback>, + + /// Whether the autocomplete is disabled. + #[props(default)] + pub disabled: ReadSignal, + + /// The controlled open state of the popup. + #[props(default)] + pub open: ReadSignal>, + + /// The initial open state when uncontrolled. + #[props(default)] + pub default_open: ReadSignal, + + /// Callback fired when the popup open state changes. + #[props(default)] + pub on_open_change: Callback, + + /// The controlled text query used to filter options. + #[props(default)] + pub query: ReadSignal>, + + /// The initial text query when uncontrolled. + #[props(default)] + pub default_query: ReadSignal, + + /// Callback fired when the text query changes. + #[props(default)] + pub on_query_change: Callback, + + /// Whether arrow-key navigation should wrap. + #[props(default = ReadSignal::new(Signal::new(true)))] + pub roving_loop: ReadSignal, + + /// Custom filter callback. Receives `(query, option_text_value)`. + #[props(default = Callback::new(|(q, t): (String, String)| default_combobox_filter(&q, &t)))] + pub filter: Callback<(String, String), bool>, + + /// Placeholder shown when the input is empty. + #[props(default)] + pub placeholder: ReadSignal, + + /// Additional attributes for the root element. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// Option children. + pub children: Element, +} + +/// A string autocomplete built on top of [`Combobox`]. +#[component] +pub fn Autocomplete(props: AutocompleteProps) -> Element { + rsx! { + Combobox:: { + value: props.value, + default_value: props.default_value, + on_value_change: props.on_value_change, + disabled: props.disabled, + open: props.open, + default_open: props.default_open, + on_open_change: props.on_open_change, + query: props.query, + default_query: props.default_query, + on_query_change: props.on_query_change, + roving_loop: props.roving_loop, + filter: props.filter, + attributes: props.attributes, + ComboboxInput { + placeholder: props.placeholder, + } + ComboboxOptions { + {props.children} + } + } + } +} + +/// Props for [`MultiSelect`]. +#[derive(Props, Clone, PartialEq)] +pub struct MultiSelectProps { + /// The controlled list of selected values. + #[props(default)] + pub values: ReadSignal>>, + + /// The default list of selected values. + #[props(default)] + pub default_values: Vec, + + /// Callback when the list of selected values changes. + #[props(default)] + pub on_values_change: Callback>, + + /// Whether the multi-select is disabled. + #[props(default)] + pub disabled: ReadSignal, + + /// The controlled open state of the popup. + #[props(default)] + pub open: ReadSignal>, + + /// The initial open state when uncontrolled. + #[props(default)] + pub default_open: ReadSignal, + + /// Callback fired when the popup open state changes. + #[props(default)] + pub on_open_change: Callback, + + /// The controlled search query. + #[props(default)] + pub query: ReadSignal>, + + /// The initial search query when uncontrolled. + #[props(default)] + pub default_query: ReadSignal, + + /// Callback fired when the search query changes. + #[props(default)] + pub on_query_change: Callback, + + /// Whether arrow-key navigation should wrap. + #[props(default = ReadSignal::new(Signal::new(true)))] + pub roving_loop: ReadSignal, + + /// Custom filter callback. Receives `(query, option_text_value)`. + #[props(default = Callback::new(|(q, t): (String, String)| default_combobox_filter(&q, &t)))] + pub filter: Callback<(String, String), bool>, + + /// Search placeholder. + #[props(default)] + pub placeholder: ReadSignal, + + /// Maximum number of selected values. + #[props(default)] + pub max_values: Option, + + /// Renders a selected value as a pill inside the target. + #[props(default)] + pub render_value: Option>, + + /// Additional attributes for the root element. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// Option children. + pub children: Element, +} + +/// A searchable multi-select built on the combobox store. +#[component] +pub fn MultiSelect(props: MultiSelectProps) -> Element { + let (values_state, set_values) = + use_controlled(props.values, props.default_values, props.on_values_change); + let values = use_memo(move || { + values_state() + .into_iter() + .map(RcPartialEqValue::new) + .collect() + }); + let set_value = use_callback(move |incoming: RcPartialEqValue| { + let value = incoming + .as_ref::() + .unwrap_or_else(|| panic!("MultiSelect and option value types must match")) + .clone(); + let mut current = values_state(); + if let Some(index) = current.iter().position(|item| item == &value) { + current.remove(index); + } else { + if props + .max_values + .is_some_and(|max_values| current.len() >= max_values) + { + return; + } + current.push(value); + } + set_values.call(current); + }); + + let open = use_combobox_root( + values, + set_value, + SelectionMode::Multiple, + props.disabled, + props.roving_loop, + Controlled { + value: props.open, + default: props.default_open, + on_change: props.on_open_change, + }, + Controlled { + value: props.query, + default: props.default_query, + on_change: props.on_query_change, + }, + props.filter, + ); + + rsx! { + div { + "data-state": if open() { "open" } else { "closed" }, + "data-disabled": (props.disabled)(), + ..props.attributes, + ComboboxTarget { + "data-pills-input": true, + if let Some(render_value) = props.render_value { + for (index, value) in values_state().into_iter().enumerate() { + Pill { + key: "{index}", + on_remove: Some(Callback::new(move |_| { + let mut next = values_state(); + if index < next.len() { + next.remove(index); + set_values.call(next); + } + })), + {render_value.call(value)} + } + } + } + ComboboxSearch { + placeholder: props.placeholder, + show_selected_text: false, + } + } + ComboboxDropdownTarget { + ComboboxOptions { + {props.children} + } + } + } + } +} + +/// Props for [`PillsInput`]. +#[derive(Props, Clone, PartialEq)] +pub struct PillsInputProps { + /// Whether the input is disabled. + #[props(default)] + pub disabled: ReadSignal, + + /// Additional attributes for the root element. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// Pill and input children. + pub children: Element, +} + +/// A layout primitive for pill-based inputs. +#[component] +pub fn PillsInput(props: PillsInputProps) -> Element { + rsx! { + div { + role: "group", + "data-pills-input": true, + "data-disabled": (props.disabled)(), + ..props.attributes, + {props.children} + } + } +} + +/// Props for [`Pill`]. +#[derive(Props, Clone, PartialEq)] +pub struct PillProps { + /// Callback fired when the remove button is pressed. + #[props(default)] + pub on_remove: Option>, + + /// Additional attributes for the pill. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// Pill label. + pub children: Element, +} + +/// A removable pill item. +#[component] +pub fn Pill(props: PillProps) -> Element { + rsx! { + span { + "data-pill": true, + ..props.attributes, + {props.children} + if let Some(on_remove) = props.on_remove { + button { + type: "button", + aria_label: "Remove", + onclick: move |_| on_remove.call(()), + "×" + } + } + } + } +} + +/// Props for [`TagsInput`]. +#[derive(Props, Clone, PartialEq)] +pub struct TagsInputProps { + /// The controlled tag list. + #[props(default)] + pub values: ReadSignal>>, + + /// The default tag list. + #[props(default)] + pub default_values: Vec, + + /// Callback fired when tags change. + #[props(default)] + pub on_values_change: Callback>, + + /// Placeholder for the text input. + #[props(default)] + pub placeholder: ReadSignal, + + /// Whether duplicate tags are allowed. + #[props(default)] + pub allow_duplicates: ReadSignal, + + /// Whether the tags input is disabled. + #[props(default)] + pub disabled: ReadSignal, + + /// Additional attributes for the root element. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, +} + +/// A basic tags input that owns tag parsing and pill removal. +#[component] +pub fn TagsInput(props: TagsInputProps) -> Element { + let (values, set_values) = + use_controlled(props.values, props.default_values, props.on_values_change); + let mut input = use_signal(String::new); + + let add_tag = use_callback(move |tag: String| { + let tag = tag.trim().to_string(); + if tag.is_empty() { + return; + } + let mut next = values(); + if !(props.allow_duplicates)() && next.iter().any(|item| item == &tag) { + return; + } + next.push(tag); + set_values.call(next); + }); + + rsx! { + PillsInput { + disabled: props.disabled, + attributes: props.attributes, + for (index, tag) in values().into_iter().enumerate() { + Pill { + key: "{tag}-{index}", + on_remove: Some(Callback::new(move |_| { + let mut next = values(); + if index < next.len() { + next.remove(index); + set_values.call(next); + } + })), + "{tag}" + } + } + input { + type: "text", + "data-pills-input-field": true, + disabled: (props.disabled)(), + value: input(), + placeholder: props.placeholder, + oninput: move |event| { + input.set(event.value()); + }, + onkeydown: move |event| { + let key = event.key(); + let should_add = matches!(key, Key::Enter) + || matches!(key, Key::Character(ref value) if value == ","); + if should_add { + add_tag.call(input()); + input.set(String::new()); + event.prevent_default(); + event.stop_propagation(); + return; + } + + match key { + Key::Backspace if input().is_empty() => { + let mut next = values(); + if next.pop().is_some() { + set_values.call(next); + } + } + _ => {} + } + }, + } + } + } +} diff --git a/primitives/src/combobox/components/input.rs b/primitives/src/combobox/components/input.rs index 286d3af5a..43f3b44ad 100644 --- a/primitives/src/combobox/components/input.rs +++ b/primitives/src/combobox/components/input.rs @@ -1,9 +1,8 @@ -//! Combobox input component. +//! Combobox input and search components. use dioxus::prelude::*; -use super::super::context::ComboboxContext; -use crate::{use_id_or, use_unique_id}; +use super::target::render_combobox_search; /// Props for [`ComboboxInput`]. #[derive(Props, Clone, PartialEq)] @@ -21,130 +20,40 @@ pub struct ComboboxInputProps { pub attributes: Vec, } -/// The text input that opens and filters the popup list. +/// Compatibility input that acts as the target, events target, and search input. #[component] pub fn ComboboxInput(props: ComboboxInputProps) -> Element { - let mut ctx = use_context::(); - - let id = use_unique_id(); - let id = use_id_or(id, props.id); - - let open = ctx.selectable.open; - let query = ctx.query; - let set_query = ctx.set_query; - - let active_descendant = use_memo(move || { - if !open() { - return None; - } - ctx.focused_option_id() - }); - - let display_value = use_memo(move || { - if open() { - query.cloned() - } else { - ctx.selectable.selected_text().unwrap_or_default() - } - }); - - let onkeydown = move |event: KeyboardEvent| match event.key() { - Key::ArrowDown => { - if !open() { - ctx.open_with_empty_query_and_focus_first(); - } else { - ctx.focus_next_visible(); - } - event.prevent_default(); - event.stop_propagation(); - } - Key::ArrowUp => { - if !open() { - ctx.open_with_empty_query_and_focus_last(); - } else { - ctx.focus_prev_visible(); - } - event.prevent_default(); - event.stop_propagation(); - } - Key::Home if open() => { - ctx.focus_first_visible(); - event.prevent_default(); - event.stop_propagation(); - } - Key::End if open() => { - ctx.focus_last_visible(); - event.prevent_default(); - event.stop_propagation(); - } - Key::Enter if open() => { - ctx.select_focused(); - event.prevent_default(); - event.stop_propagation(); - } - Key::Escape if open() => { - ctx.set_open(false); - event.prevent_default(); - event.stop_propagation(); - } - _ => {} - }; + render_combobox_search(props.placeholder, props.id, props.attributes, true, true) +} - rsx! { - input { - id, - r#type: "text", - value: display_value(), - placeholder: props.placeholder, - autocomplete: "off", - spellcheck: "false", - disabled: (ctx.selectable.disabled)(), +/// Props for [`ComboboxSearch`]. +#[derive(Props, Clone, PartialEq)] +pub struct ComboboxSearchProps { + /// Placeholder shown when the input is empty. + #[props(default)] + pub placeholder: ReadSignal, - role: "combobox", - aria_autocomplete: "list", - aria_haspopup: "listbox", - aria_expanded: open(), - aria_controls: ctx.selectable.list_id, - aria_activedescendant: active_descendant(), + /// Optional id for the input element. + #[props(default)] + pub id: ReadSignal>, - "data-state": if open() { "open" } else { "closed" }, + /// Whether to show selected option text while the dropdown is closed. + #[props(default = true)] + pub show_selected_text: bool, - onclick: move |_| { - if !open() { - set_query.call(String::new()); - ctx.set_open(true); - } - }, - oninput: move |event| { - let was_open = open(); - let value = event.value(); - let next_query = if was_open { - value - } else { - ctx.selectable - .selected_text() - .and_then(|selected| { - value - .strip_prefix(&selected) - .map(ToString::to_string) - }) - .unwrap_or(value) - }; - set_query.call(next_query); - if was_open { - ctx.selectable.focus_state.set_focus(None); - } else { - ctx.set_open(true); - } - }, - onkeydown, - onblur: move |_| { - if open() { - ctx.set_open(false); - } - }, + /// Additional attributes. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, +} - ..props.attributes, - } - } +/// Search input for split combobox anatomy. +#[component] +pub fn ComboboxSearch(props: ComboboxSearchProps) -> Element { + render_combobox_search( + props.placeholder, + props.id, + props.attributes, + false, + props.show_selected_text, + ) } diff --git a/primitives/src/combobox/components/list.rs b/primitives/src/combobox/components/list.rs index 95fae400e..915c03841 100644 --- a/primitives/src/combobox/components/list.rs +++ b/primitives/src/combobox/components/list.rs @@ -1,13 +1,13 @@ -//! ComboboxList component. +//! Combobox options components. use dioxus::prelude::*; use super::super::context::ComboboxContext; -use crate::listbox::use_listbox_container; +use crate::listbox::{use_listbox_container_with_open, use_listbox_id}; -/// Props for [`ComboboxList`]. +/// Props for [`ComboboxOptions`]. #[derive(Props, Clone, PartialEq)] -pub struct ComboboxListProps { +pub struct ComboboxOptionsProps { /// Optional id for the list element. #[props(default)] pub id: ReadSignal>, @@ -23,10 +23,11 @@ pub struct ComboboxListProps { /// Listbox that contains the visible options. #[component] -pub fn ComboboxList(props: ComboboxListProps) -> Element { +pub fn ComboboxOptions(props: ComboboxOptionsProps) -> Element { let ctx = use_context::(); - let open = ctx.selectable.open; - let listbox = use_listbox_container(props.id, ctx.selectable); + let open = use_memo(move || ctx.store.dropdown_opened()); + let id = use_listbox_id(props.id, ctx.selectable.list_id); + let listbox = use_listbox_container_with_open(id, ctx.selectable, open); let render = listbox.render; rsx! { @@ -46,3 +47,18 @@ pub fn ComboboxList(props: ComboboxListProps) -> Element { } } } + +/// Compatibility props alias for [`ComboboxOptions`]. +pub type ComboboxListProps = ComboboxOptionsProps; + +/// Compatibility alias for [`ComboboxOptions`]. +#[component] +pub fn ComboboxList(props: ComboboxListProps) -> Element { + rsx! { + ComboboxOptions { + id: props.id, + attributes: props.attributes, + {props.children} + } + } +} diff --git a/primitives/src/combobox/components/mod.rs b/primitives/src/combobox/components/mod.rs index f46c66936..0f5521dfd 100644 --- a/primitives/src/combobox/components/mod.rs +++ b/primitives/src/combobox/components/mod.rs @@ -2,14 +2,28 @@ pub mod combobox; pub mod empty; +pub mod high_level; pub mod input; pub mod list; pub mod option; +pub mod target; +pub mod virtualized; pub use combobox::{Combobox, ComboboxProps}; pub use empty::{ComboboxEmpty, ComboboxEmptyProps}; -pub use input::{ComboboxInput, ComboboxInputProps}; -pub use list::{ComboboxList, ComboboxListProps}; +pub use high_level::{ + Autocomplete, AutocompleteProps, MultiSelect, MultiSelectProps, Pill, PillProps, PillsInput, + PillsInputProps, TagsInput, TagsInputProps, +}; +pub use input::{ComboboxInput, ComboboxInputProps, ComboboxSearch, ComboboxSearchProps}; +pub use list::{ComboboxList, ComboboxListProps, ComboboxOptions, ComboboxOptionsProps}; pub use option::{ ComboboxItemIndicator, ComboboxItemIndicatorProps, ComboboxOption, ComboboxOptionProps, }; +pub use target::{ + use_combobox_dropdown_target_attributes, use_combobox_events_target_attributes, + use_combobox_search_attributes, use_combobox_target_attributes, ComboboxDropdownTarget, + ComboboxDropdownTargetProps, ComboboxEventsTarget, ComboboxEventsTargetProps, ComboboxTarget, + ComboboxTargetProps, +}; +pub use virtualized::{VirtualizedComboboxOptions, VirtualizedComboboxOptionsProps}; diff --git a/primitives/src/combobox/components/option.rs b/primitives/src/combobox/components/option.rs index e40c9d8cb..099c51960 100644 --- a/primitives/src/combobox/components/option.rs +++ b/primitives/src/combobox/components/option.rs @@ -2,13 +2,15 @@ use dioxus::prelude::*; -use super::super::context::ComboboxContext; +use super::super::{context::ComboboxContext, hook::ComboboxDropdownEventSource}; use crate::{ listbox::{ListboxContext, ListboxItemIndicator}, selectable::{ pointer_select_cancel, pointer_select_commit, pointer_select_start, use_selectable_option, - RcPartialEqValue, SelectableOptionConfig, + SelectableOptionConfig, }, + selection::option_text_value, + use_effect_with_cleanup, }; /// Props for [`ComboboxOption`]. @@ -54,7 +56,10 @@ pub fn ComboboxOption(props: ComboboxOptionProps let index = props.index; let mut ctx: ComboboxContext = use_context(); - let visible = move || ctx.is_visible(index()); + let text_value = use_memo(move || { + option_text_value(&*props.value.read(), (props.text_value)(), "ComboboxOption") + }); + let visible = move || ctx.is_visible_text(index(), text_value.cloned()); let option = use_selectable_option( ctx.selectable, SelectableOptionConfig { @@ -66,6 +71,17 @@ pub fn ComboboxOption(props: ComboboxOptionProps component_name: "ComboboxOption", }, ); + use_effect_with_cleanup({ + let store = ctx.store; + let id = option.id; + let disabled = option.disabled; + let selected = option.selected; + move || { + let id_value = id.cloned(); + store.register_option(id_value.clone(), index(), disabled(), visible(), selected()); + move || store.unregister_option(&id_value) + } + }); let render = use_context::().render; @@ -87,6 +103,7 @@ pub fn ComboboxOption(props: ComboboxOptionProps onmouseenter: move |_| { if !(option.disabled)() { ctx.selectable.focus_state.set_focus(Some((option.index)())); + ctx.store.select_option((option.index)()); } }, onpointerdown: move |event| { @@ -94,7 +111,7 @@ pub fn ComboboxOption(props: ComboboxOptionProps }, onpointerup: move |event| { if pointer_select_commit(&event, (option.disabled)(), option.down_pos) { - ctx.selectable.select_value(RcPartialEqValue::new(option.value.cloned())); + ctx.submit_index((option.index)(), ComboboxDropdownEventSource::Mouse); } }, onpointercancel: move |_| { diff --git a/primitives/src/combobox/components/target.rs b/primitives/src/combobox/components/target.rs new file mode 100644 index 000000000..b695a10a3 --- /dev/null +++ b/primitives/src/combobox/components/target.rs @@ -0,0 +1,361 @@ +//! Combobox target anatomy components. + +use dioxus::prelude::*; + +use super::super::{context::ComboboxContext, hook::ComboboxDropdownEventSource}; +use crate::{dioxus_attributes::attributes, merge_attributes, use_unique_id}; + +fn active_descendant(ctx: ComboboxContext, open: Memo) -> Memo> { + use_memo(move || { + if !open() { + return None; + } + ctx.focused_option_id() + }) +} + +fn handle_events_target_keydown(event: KeyboardEvent, mut ctx: ComboboxContext, open: Memo) { + if (ctx.selectable.disabled)() { + event.prevent_default(); + event.stop_propagation(); + return; + } + + match event.key() { + Key::ArrowDown => { + if !open() { + ctx.open_with_empty_query_and_focus_first(); + } else { + ctx.focus_next_visible(); + } + event.prevent_default(); + event.stop_propagation(); + } + Key::ArrowUp => { + if !open() { + ctx.open_with_empty_query_and_focus_last(); + } else { + ctx.focus_prev_visible(); + } + event.prevent_default(); + event.stop_propagation(); + } + Key::Home if open() => { + ctx.focus_first_visible(); + event.prevent_default(); + event.stop_propagation(); + } + Key::End if open() => { + ctx.focus_last_visible(); + event.prevent_default(); + event.stop_propagation(); + } + Key::Enter if open() => { + ctx.select_focused(); + event.prevent_default(); + event.stop_propagation(); + } + Key::Escape if open() => { + ctx.set_open(false); + event.prevent_default(); + event.stop_propagation(); + } + _ => {} + } +} + +/// Returns attributes that register the current element as the combobox focus target. +pub fn use_combobox_target_attributes() -> Vec { + let ctx = use_context::(); + + attributes!(div { + "data-combobox-target": true, + onmounted: move |event| { + ctx.store.register_target_mount_ref(event.data()); + }, + }) +} + +fn use_combobox_target_wrapper_attributes() -> Vec { + attributes!(div { + "data-combobox-target": true, + }) +} + +/// Props for [`ComboboxTarget`]. +#[derive(Props, Clone, PartialEq)] +pub struct ComboboxTargetProps { + /// Optional custom element renderer for the target attributes. + #[props(default)] + pub r#as: Option, Element>>, + + /// Additional attributes. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// Children rendered inside the target wrapper. + pub children: Element, +} + +/// Renders a structural wrapper around the combobox target area. +/// +/// Use [`use_combobox_target_attributes`] on a custom interactive element when +/// that element should receive [`ComboboxStore::focus_target`](crate::combobox::ComboboxStore::focus_target). +#[component] +pub fn ComboboxTarget(props: ComboboxTargetProps) -> Element { + if let Some(dynamic) = props.r#as { + let merged = merge_attributes(vec![use_combobox_target_attributes(), props.attributes]); + return dynamic.call(merged); + } + + let merged = merge_attributes(vec![ + use_combobox_target_wrapper_attributes(), + props.attributes, + ]); + + rsx! { + div { + ..merged, + {props.children} + } + } +} + +/// Returns attributes for the combobox events target. +pub fn use_combobox_events_target_attributes() -> Vec { + let ctx = use_context::(); + let open = use_memo(move || ctx.store.dropdown_opened()); + let active_descendant = active_descendant(ctx, open); + let disabled = ctx.selectable.disabled; + + attributes!(div { + role: "combobox", + tabindex: if disabled() { "-1" } else { "0" }, + aria_haspopup: "listbox", + aria_expanded: open(), + aria_controls: ctx.selectable.list_id(), + aria_activedescendant: active_descendant(), + aria_disabled: disabled(), + "data-combobox-events-target": true, + "data-state": if open() { "open" } else { "closed" }, + "data-disabled": disabled(), + onclick: move |event| { + if disabled() { + event.prevent_default(); + event.stop_propagation(); + return; + } + if !open() { + ctx.set_query.call(String::new()); + } + ctx.store + .toggle_dropdown(ComboboxDropdownEventSource::Mouse); + }, + onkeydown: move |event| { + handle_events_target_keydown(event, ctx, open); + }, + }) +} + +/// Props for [`ComboboxEventsTarget`]. +#[derive(Props, Clone, PartialEq)] +pub struct ComboboxEventsTargetProps { + /// Optional custom element renderer for the events target attributes. + #[props(default)] + pub r#as: Option, Element>>, + + /// Additional attributes. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// Children rendered inside the events target. + pub children: Element, +} + +/// Element that owns combobox trigger ARIA and keyboard/pointer interactions. +#[component] +pub fn ComboboxEventsTarget(props: ComboboxEventsTargetProps) -> Element { + let merged = merge_attributes(vec![ + use_combobox_events_target_attributes(), + props.attributes, + ]); + + if let Some(dynamic) = props.r#as { + return dynamic.call(merged); + } + + rsx! { + div { + ..merged, + {props.children} + } + } +} + +/// Returns attributes for an element that marks the dropdown anchoring target. +pub fn use_combobox_dropdown_target_attributes() -> Vec { + let ctx = use_context::(); + let open = use_memo(move || ctx.store.dropdown_opened()); + + attributes!(div { + "data-combobox-dropdown-target": true, + "data-state": if open() { "open" } else { "closed" }, + }) +} + +/// Props for [`ComboboxDropdownTarget`]. +#[derive(Props, Clone, PartialEq)] +pub struct ComboboxDropdownTargetProps { + /// Optional custom element renderer for the dropdown target attributes. + #[props(default)] + pub r#as: Option, Element>>, + + /// Additional attributes. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// Children rendered inside the dropdown target. + pub children: Element, +} + +/// Wraps dropdown content when the dropdown target differs from the events target. +#[component] +pub fn ComboboxDropdownTarget(props: ComboboxDropdownTargetProps) -> Element { + let merged = merge_attributes(vec![ + use_combobox_dropdown_target_attributes(), + props.attributes, + ]); + + if let Some(dynamic) = props.r#as { + return dynamic.call(merged); + } + + rsx! { + div { + ..merged, + {props.children} + } + } +} + +/// Returns attributes for a native combobox search input. +pub fn use_combobox_search_attributes( + placeholder: ReadSignal, + id: ReadSignal>, + register_target: bool, + show_selected_text: bool, +) -> Vec { + let mut ctx = use_context::(); + + let fallback_id = use_unique_id(); + let id = crate::use_id_or(fallback_id, id); + + let open = use_memo(move || ctx.store.dropdown_opened()); + let query = ctx.query; + let set_query = ctx.set_query; + let active_descendant = active_descendant(ctx, open); + + let display_value = use_memo(move || { + if open() { + query.cloned() + } else if show_selected_text { + ctx.selectable.selected_text().unwrap_or_default() + } else { + String::new() + } + }); + let disabled = ctx.selectable.disabled; + + attributes!(input { + id, + r#type: "text", + value: display_value(), + placeholder, + autocomplete: "off", + spellcheck: "false", + disabled: disabled(), + + role: "combobox", + aria_autocomplete: "list", + aria_haspopup: "listbox", + aria_expanded: open(), + aria_controls: ctx.selectable.list_id(), + aria_activedescendant: active_descendant(), + + "data-combobox-search": true, + "data-state": if open() { "open" } else { "closed" }, + + onclick: move |event| { + if disabled() { + event.prevent_default(); + event.stop_propagation(); + return; + } + if !open() { + set_query.call(String::new()); + ctx.set_open(true); + } + }, + oninput: move |event| { + if disabled() { + event.prevent_default(); + event.stop_propagation(); + return; + } + let was_open = open(); + let value = event.value(); + let next_query = if was_open { + value + } else { + ctx.selectable + .selected_text() + .and_then(|selected| { + value + .strip_prefix(&selected) + .map(ToString::to_string) + }) + .unwrap_or(value) + }; + set_query.call(next_query); + if was_open { + ctx.selectable.focus_state.set_focus(None); + ctx.store.reset_selected_option(); + } else { + ctx.set_open(true); + } + }, + onkeydown: move |event| { + handle_events_target_keydown(event, ctx, open); + }, + onmounted: move |event| { + if register_target { + ctx.store.register_target_mount_ref(event.data()); + } + ctx.store.register_search_mount_ref(event.data()); + }, + onblur: move |_| { + if open() { + ctx.set_open(false); + } + }, + }) +} + +pub(super) fn render_combobox_search( + placeholder: ReadSignal, + id: ReadSignal>, + attributes: Vec, + register_target: bool, + show_selected_text: bool, +) -> Element { + let merged = merge_attributes(vec![ + use_combobox_search_attributes(placeholder, id, register_target, show_selected_text), + attributes, + ]); + + rsx! { + input { + ..merged, + } + } +} diff --git a/primitives/src/combobox/components/virtualized.rs b/primitives/src/combobox/components/virtualized.rs new file mode 100644 index 000000000..d710445aa --- /dev/null +++ b/primitives/src/combobox/components/virtualized.rs @@ -0,0 +1,284 @@ +//! Virtualized combobox listbox component. + +use dioxus::prelude::*; +use serde::Deserialize; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + +use super::super::context::ComboboxContext; +use crate::{ + listbox::{use_listbox_container_with_open, use_listbox_id}, + r#virtual::{ + compute_measurements, get_total_size, get_virtual_items, resize_item, set_scroll_offset, + set_viewport_size, VirtualizerState, VirtualizerStateStoreExt, + }, +}; + +/// Props for [`VirtualizedComboboxOptions`]. +#[derive(Props, Clone, PartialEq)] +pub struct VirtualizedComboboxOptionsProps { + /// The total number of options. + pub count: ReadSignal, + + /// Optional visible-row to absolute-option index mapping. + /// + /// When provided, the virtualizer only materializes the mapped rows and passes the underlying + /// absolute option index into [`Self::render_option`] and [`Self::estimate_size`]. + #[props(default)] + pub visible_indices: Option>>, + + /// The amount of render buffer in estimated row counts. + #[props(default = ReadSignal::new(Signal::new(8)))] + pub buffer: ReadSignal, + + /// Estimates the height of an option by absolute index. + pub estimate_size: Option>, + + /// Renders one option by absolute index. + pub render_option: Callback, + + /// Optional id for the listbox. + #[props(default)] + pub id: ReadSignal>, + + /// Additional attributes for the listbox scroll container. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, +} + +/// A virtualized combobox listbox that preserves listbox/option semantics. +#[component] +pub fn VirtualizedComboboxOptions(props: VirtualizedComboboxOptionsProps) -> Element { + let ctx = use_context::(); + let open = use_memo(move || ctx.store.dropdown_opened()); + let id = use_listbox_id(props.id, ctx.selectable.list_id); + let listbox = use_listbox_container_with_open(id, ctx.selectable, open); + let render = listbox.render; + + let state: Store = use_store(VirtualizerState::new); + let visible_indices = use_memo(move || { + props + .visible_indices + .as_ref() + .map(|indices| indices.read().clone()) + .unwrap_or_else(|| (0..(props.count)()).collect()) + }); + let visible_signature = use_memo(move || { + let mut hasher = DefaultHasher::new(); + for index in visible_indices.read().iter() { + index.hash(&mut hasher); + } + hasher.finish() + }); + + use_effect(move || { + let _ = visible_signature(); + state.item_size_cache().write().clear(); + state.scroll_adjustments().set(0); + state.deferred_adjustments().set(0); + state.stable_total_size().set(None); + state.stable_measurement_count().set(None); + }); + + let measurements = use_memo(move || { + let item_size_cache = state.item_size_cache(); + let item_size_cache = item_size_cache.read(); + let visible_indices = visible_indices.read(); + let visible_count = visible_indices.len(); + let estimate_cb = props + .estimate_size + .as_ref() + .map(|callback| move |index| callback(visible_indices[index])); + compute_measurements( + visible_count, + &item_size_cache, + estimate_cb + .as_ref() + .map(|estimate| estimate as &dyn Fn(usize) -> u32), + ) + }); + + use_effect(move || { + if !render() { + return; + } + + let script = r#" + const container = document.getElementById(await dioxus.recv()); + if (!container) return; + + let scrollEndTimer = null; + function publish(isScrolling) { + dioxus.send({ + offset: Math.round(container.scrollTop), + viewport: Math.min(container.clientHeight, window.innerHeight) || 240, + isScrolling + }); + } + + function onScroll() { + if (scrollEndTimer !== null) clearTimeout(scrollEndTimer); + publish(true); + scrollEndTimer = setTimeout(() => { + scrollEndTimer = null; + publish(false); + }, 300); + } + + publish(false); + container.addEventListener("scroll", onScroll, { passive: true }); + window.addEventListener("resize", () => publish(false), { passive: true }); + + await dioxus.recv(); + if (scrollEndTimer !== null) clearTimeout(scrollEndTimer); + container.removeEventListener("scroll", onScroll); + "#; + let mut eval = document::eval(script); + let _ = eval.send(listbox.id.peek().clone()); + + spawn(async move { + while let Ok(scroll_msg) = eval.recv::().await { + let correction = { + let measurements = measurements.peek(); + set_scroll_offset( + &state, + &measurements, + scroll_msg.offset, + scroll_msg.is_scrolling, + ) + }; + set_viewport_size(&state, scroll_msg.viewport); + + if let Some(delta) = correction { + let next = (scroll_msg.offset as i32 + delta).max(0) as u32; + sync_scroll(listbox.id.peek().clone(), next).await; + state.scroll_offset().set(next); + } + } + }); + }); + + use_effect(move || { + if !render() { + return; + } + let Some(highlighted_index) = ctx.store.highlighted_option_index() else { + return; + }; + let visible_indices = visible_indices.peek(); + let Some(visible_index) = visible_indices + .iter() + .position(|index| *index == highlighted_index) + else { + return; + }; + let measurements = measurements.peek(); + let Some(item) = measurements + .iter() + .find(|item| item.index() == visible_index) + else { + return; + }; + let start = item.start(); + let end = item.end(); + let current = *state.scroll_offset().peek(); + let viewport = *state.viewport_size().peek(); + let next = if start < current { + Some(start) + } else if end > current.saturating_add(viewport) { + Some(end.saturating_sub(viewport)) + } else { + None + }; + if let Some(next) = next { + spawn(async move { + sync_scroll(listbox.id.peek().clone(), next).await; + }); + state.scroll_offset().set(next); + } + }); + + let onresize = move |index| { + move |event: Event| { + let rect = event.data().get_content_box_size().unwrap_or_default(); + let measured = rect.height.max(1.0).round() as u32; + let measurements = measurements.peek(); + let adjustment = resize_item(&state, &measurements, index, measured); + drop(measurements); + if let Some(delta) = adjustment { + let current = *state.scroll_offset().peek(); + let next = (current as i32 + delta).max(0) as u32; + spawn(async move { + sync_scroll(listbox.id.peek().clone(), next).await; + }); + } + } + }; + + let measurements_read = measurements.read(); + let virtual_items = get_virtual_items(&state, &measurements_read, (props.buffer)()); + let total_height = get_total_size(&state, &measurements_read); + let top_offset = virtual_items.first().map(|item| item.start()).unwrap_or(0); + let canvas_height = total_height.max(*state.viewport_size().peek()); + let set_size = visible_indices.read().len().to_string(); + + rsx! { + if render() { + div { + id: listbox.id, + role: "listbox", + "data-state": if open() { "open" } else { "closed" }, + onpointerdown: move |event| { + event.prevent_default(); + }, + ..props.attributes, + div { + style: "position: relative; height:{canvas_height}px; width: 100%;", + div { + style: "position: absolute; inset: 0 auto auto 0; width: 100%; transform: translateY({top_offset}px); will-change: transform;", + {virtual_items.iter().map(move |item| { + let visible_index = item.index(); + let index = visible_indices.read()[visible_index]; + rsx! { + div { + key: "{item.key()}", + role: "presentation", + "data-virtual-index": "{index}", + "aria-setsize": "{set_size}", + "aria-posinset": "{visible_index + 1}", + onresize: onresize(visible_index), + {(props.render_option)(index)} + } + } + })} + } + } + } + } else { + {visible_indices.read().iter().map(move |index| rsx! { + {(props.render_option)(*index)} + })} + } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct VirtualScrollMsg { + offset: u32, + viewport: u32, + is_scrolling: bool, +} + +async fn sync_scroll(container_id: String, scroll_top: u32) { + let eval = document::eval( + r#" + const id = await dioxus.recv(); + const scrollTop = await dioxus.recv(); + const container = document.getElementById(id); + if (container) container.scrollTop = scrollTop; + "#, + ); + let _ = eval.send(container_id); + let _ = eval.send(scroll_top); +} diff --git a/primitives/src/combobox/context.rs b/primitives/src/combobox/context.rs index 052760ac1..8452b5140 100644 --- a/primitives/src/combobox/context.rs +++ b/primitives/src/combobox/context.rs @@ -1,5 +1,6 @@ //! Shared state for the combobox component. +use super::hook::{ComboboxDropdownEventSource, ComboboxIndexTarget, ComboboxStore}; use crate::selectable::{OptionState, SelectableContext}; use dioxus::prelude::*; @@ -12,17 +13,26 @@ pub fn default_combobox_filter(query: &str, text: &str) -> bool { #[derive(Clone, Copy)] pub(super) struct ComboboxContext { pub selectable: SelectableContext, + pub store: ComboboxStore, pub query: Memo, pub set_query: Callback, pub filter: Callback<(String, String), bool>, } impl ComboboxContext { + fn matches_query_text(&self, text: String) -> bool { + self.filter.call((self.query.cloned(), text)) + } + pub fn set_open(&mut self, open: bool) { if open { self.selectable.focus_state.set_focus(None); + self.store + .open_dropdown(ComboboxDropdownEventSource::Unknown); + } else { + self.store + .close_dropdown(ComboboxDropdownEventSource::Unknown); } - self.selectable.set_open(open); } fn predicate_for(&self, query: String) -> impl Fn(&OptionState) -> bool { @@ -44,6 +54,16 @@ impl ComboboxContext { .is_some_and(predicate) } + pub fn is_visible_text(&self, tab_index: usize, text: String) -> bool { + let predicate = self.predicate(); + self.selectable + .options + .read() + .iter() + .find(|option| option.tab_index == tab_index) + .map_or_else(|| self.matches_query_text(text), predicate) + } + pub fn has_visible_options(&self) -> bool { self.selectable.options.read().iter().any(self.predicate()) } @@ -55,6 +75,9 @@ impl ComboboxContext { .selectable .first_matching_enabled_index(self.predicate_for(query)); self.selectable.initial_focus.set(initial_focus); + self.store.update_selected_option_index( + initial_focus.map_or(ComboboxIndexTarget::None, |_| ComboboxIndexTarget::First), + ); self.set_open(true); } @@ -65,30 +88,78 @@ impl ComboboxContext { .selectable .last_matching_enabled_index(self.predicate_for(query)); self.selectable.initial_focus.set(initial_focus); + self.store.update_selected_option_index( + initial_focus.map_or(ComboboxIndexTarget::None, |_| ComboboxIndexTarget::Last), + ); self.set_open(true); } pub fn focused_option_id(&self) -> Option { - self.selectable.focused_option_id() + self.store + .highlighted_option_index() + .and_then(|index| { + self.selectable + .options + .read() + .iter() + .find(|option| option.tab_index == index && !option.disabled) + .map(|option| option.id.clone()) + }) + .or_else(|| self.selectable.focused_option_id()) } pub fn focus_next_visible(&mut self) { self.selectable.focus_next_where(self.predicate()); + if let Some(index) = self.selectable.focus_state.current_focus() { + self.store.select_option(index); + } } pub fn focus_prev_visible(&mut self) { self.selectable.focus_prev_where(self.predicate()); + if let Some(index) = self.selectable.focus_state.current_focus() { + self.store.select_option(index); + } } pub fn focus_first_visible(&mut self) { self.selectable.focus_first_where(self.predicate()); + if let Some(index) = self.selectable.focus_state.current_focus() { + self.store.select_option(index); + } } pub fn focus_last_visible(&mut self) { self.selectable.focus_last_where(self.predicate()); + if let Some(index) = self.selectable.focus_state.current_focus() { + self.store.select_option(index); + } } pub fn select_focused(&mut self) { - self.selectable.select_focused(); + if let Some(index) = self.selectable.focus_state.current_focus() { + self.submit_index(index, ComboboxDropdownEventSource::Keyboard); + } + } + + pub fn submit_index(&mut self, index: usize, source: ComboboxDropdownEventSource) { + if self.store.select_option(index).is_none() { + return; + } + self.store.submit_selected_option(); + let Some(value) = self + .selectable + .options + .read() + .iter() + .find(|option| option.tab_index == index && !option.disabled) + .map(|option| option.value.clone()) + else { + return; + }; + self.selectable.select_value(value); + if !self.selectable.selection_mode.is_multiple() { + self.store.close_dropdown(source); + } } } diff --git a/primitives/src/combobox/hook.rs b/primitives/src/combobox/hook.rs new file mode 100644 index 000000000..c2877e4b3 --- /dev/null +++ b/primitives/src/combobox/hook.rs @@ -0,0 +1,582 @@ +//! Value-agnostic combobox interaction store. + +use std::rc::Rc; + +use dioxus::prelude::*; + +use crate::disclosure::{use_disclosure, Disclosure, UseDisclosureOptions}; + +/// The user interaction source that requested a dropdown state change. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ComboboxDropdownEventSource { + /// Keyboard interaction. + Keyboard, + /// Pointer or mouse interaction. + Mouse, + /// Programmatic or unknown interaction. + Unknown, +} + +/// Target used when updating the highlighted option index. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ComboboxIndexTarget { + /// Clear the highlighted option. + None, + /// Highlight the first enabled and visible option. + First, + /// Highlight the last enabled and visible option. + Last, + /// Highlight the active option when one is registered. + Active, + /// Keep the current highlighted option if still enabled and visible. + Selected, +} + +/// Stable option key returned by combobox navigation methods. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ComboboxOptionKey { + /// DOM id registered by the option. + pub id: String, + /// Navigation index registered by the option. + pub index: usize, +} + +/// Metadata for an option submitted through the combobox store. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ComboboxSubmittedOption { + /// DOM id registered by the option. + pub id: String, + /// Navigation index registered by the option. + pub index: usize, +} + +/// Options for [`use_combobox`]. +#[derive(Clone, Copy)] +pub struct UseComboboxOptions { + /// Controlled dropdown open state. + pub opened: ReadSignal>, + /// Initial dropdown open state when uncontrolled. + pub default_opened: ReadSignal, + /// Callback fired when the dropdown open state changes. + pub on_opened_change: Callback, + /// Callback fired on closed-to-open dropdown transitions. + pub on_dropdown_open: Option>, + /// Callback fired on open-to-closed dropdown transitions. + pub on_dropdown_close: Option>, + /// Whether keyboard navigation wraps at list boundaries. + pub loop_navigation: ReadSignal, +} + +/// Options for [`use_virtualized_combobox`]. +#[derive(Clone, Copy, Default)] +pub struct UseVirtualizedComboboxOptions { + /// Base combobox interaction options. + pub combobox: UseComboboxOptions, +} + +/// Store returned by [`use_virtualized_combobox`]. +pub type VirtualizedComboboxStore = ComboboxStore; + +impl Default for UseComboboxOptions { + fn default() -> Self { + Self { + opened: ReadSignal::new(Signal::new(None)), + default_opened: ReadSignal::new(Signal::new(false)), + on_opened_change: Callback::default(), + on_dropdown_open: None, + on_dropdown_close: None, + loop_navigation: ReadSignal::new(Signal::new(true)), + } + } +} + +#[derive(Clone, PartialEq)] +struct ComboboxOptionState { + key: ComboboxOptionKey, + disabled: bool, + visible: bool, + active: bool, +} + +impl ComboboxOptionState { + fn enabled_visible(&self) -> bool { + !self.disabled && self.visible + } +} + +/// A cloneable combobox store handle. +#[derive(Clone, Copy)] +pub struct ComboboxStore { + disclosure: Disclosure, + options: Signal>, + highlighted_index: Signal>, + submitted_option: Signal>, + target_mount: Signal>>, + search_mount: Signal>>, + on_dropdown_open: Option>, + on_dropdown_close: Option>, + loop_navigation: ReadSignal, +} + +impl ComboboxStore { + /// Returns whether the dropdown is open. + pub fn dropdown_opened(&self) -> bool { + self.disclosure.opened() + } + + /// Opens the dropdown and reports the event source on transition. + pub fn open_dropdown(&self, source: ComboboxDropdownEventSource) { + if self.disclosure.open() { + if let Some(on_open) = self.on_dropdown_open { + on_open.call(source); + } + } + } + + /// Closes the dropdown and reports the event source on transition. + pub fn close_dropdown(&self, source: ComboboxDropdownEventSource) { + if self.disclosure.close() { + self.reset_selected_option(); + if let Some(on_close) = self.on_dropdown_close { + on_close.call(source); + } + } + } + + /// Toggles the dropdown and reports the event source on transition. + pub fn toggle_dropdown(&self, source: ComboboxDropdownEventSource) { + match self.disclosure.toggle() { + Some(true) => { + if let Some(on_open) = self.on_dropdown_open { + on_open.call(source); + } + } + Some(false) => { + self.reset_selected_option(); + if let Some(on_close) = self.on_dropdown_close { + on_close.call(source); + } + } + None => {} + } + } + + /// Returns the currently highlighted option index. + pub fn highlighted_option_index(&self) -> Option { + (self.highlighted_index)() + } + + /// Highlights the enabled, visible option with the given index. + pub fn select_option(&self, index: usize) -> Option { + let key = self + .options + .read() + .iter() + .find(|option| option.key.index == index && option.enabled_visible()) + .map(|option| option.key.clone())?; + let mut highlighted_index = self.highlighted_index; + highlighted_index.set(Some(key.index)); + Some(key) + } + + /// Highlights the first enabled and visible option. + pub fn select_first_option(&self) -> Option { + let key = first_enabled_visible(&self.options.read())?; + let mut highlighted_index = self.highlighted_index; + highlighted_index.set(Some(key.index)); + Some(key) + } + + /// Highlights the active option, or the first enabled visible option when no + /// active option is registered. + pub fn select_active_option(&self) -> Option { + let options = self.options.read(); + let key = options + .iter() + .find(|option| option.active && option.enabled_visible()) + .or_else(|| options.iter().find(|option| option.enabled_visible())) + .map(|option| option.key.clone())?; + drop(options); + let mut highlighted_index = self.highlighted_index; + highlighted_index.set(Some(key.index)); + Some(key) + } + + /// Highlights the next enabled and visible option. + pub fn select_next_option(&self) -> Option { + let key = next_enabled_visible( + &self.options.read(), + self.highlighted_option_index(), + (self.loop_navigation)(), + )?; + let mut highlighted_index = self.highlighted_index; + highlighted_index.set(Some(key.index)); + Some(key) + } + + /// Highlights the previous enabled and visible option. + pub fn select_previous_option(&self) -> Option { + let key = previous_enabled_visible( + &self.options.read(), + self.highlighted_option_index(), + (self.loop_navigation)(), + )?; + let mut highlighted_index = self.highlighted_index; + highlighted_index.set(Some(key.index)); + Some(key) + } + + /// Clears the highlighted option. + pub fn reset_selected_option(&self) { + let mut highlighted_index = self.highlighted_index; + highlighted_index.set(None); + } + + /// Updates the highlighted option according to the requested target. + pub fn update_selected_option_index(&self, target: ComboboxIndexTarget) { + match target { + ComboboxIndexTarget::None => self.reset_selected_option(), + ComboboxIndexTarget::First => { + self.select_first_option(); + } + ComboboxIndexTarget::Last => { + if let Some(key) = last_enabled_visible(&self.options.read()) { + let mut highlighted_index = self.highlighted_index; + highlighted_index.set(Some(key.index)); + } + } + ComboboxIndexTarget::Active => { + self.select_active_option(); + } + ComboboxIndexTarget::Selected => { + if self + .highlighted_option_index() + .and_then(|index| self.select_option(index)) + .is_none() + { + self.reset_selected_option(); + } + } + } + } + + /// Returns the most recently submitted option metadata. + pub fn submitted_option(&self) -> Option { + (self.submitted_option)() + } + + /// Requests submission of the currently highlighted option. + pub fn submit_selected_option(&self) -> Option { + let index = self.highlighted_option_index()?; + let key = self.select_option(index)?; + let submitted = ComboboxSubmittedOption { + id: key.id, + index: key.index, + }; + let mut submitted_option = self.submitted_option; + submitted_option.set(Some(submitted.clone())); + Some(submitted) + } + + /// Registers the mounted target element used by [`focus_target`](Self::focus_target). + pub fn register_target_mount(&self, mounted: MountedData) { + self.register_target_mount_ref(Rc::new(mounted)); + } + + /// Registers the mounted search input used by + /// [`focus_search_input`](Self::focus_search_input). + pub fn register_search_mount(&self, mounted: MountedData) { + self.register_search_mount_ref(Rc::new(mounted)); + } + + /// Focuses the registered target element when mounted. + pub fn focus_target(&self) { + focus_mounted(self.target_mount); + } + + /// Focuses the registered search input when mounted. + pub fn focus_search_input(&self) { + focus_mounted(self.search_mount); + } + + pub(crate) fn register_option( + &self, + id: String, + index: usize, + disabled: bool, + visible: bool, + active: bool, + ) { + let mut options = self.options; + sync_combobox_option( + &mut options.write(), + ComboboxOptionState { + key: ComboboxOptionKey { id, index }, + disabled, + visible, + active, + }, + ); + if self + .highlighted_option_index() + .is_some_and(|idx| !has_enabled_visible_index(&self.options.read(), idx)) + { + self.reset_selected_option(); + } + } + + pub(crate) fn unregister_option(&self, id: &str) { + let mut options = self.options; + options.write().retain(|option| option.key.id != id); + if self + .highlighted_option_index() + .is_some_and(|idx| !has_enabled_visible_index(&self.options.read(), idx)) + { + self.reset_selected_option(); + } + } + + pub(crate) fn register_target_mount_ref(&self, mounted: Rc) { + let mut target_mount = self.target_mount; + target_mount.set(Some(mounted)); + } + + pub(crate) fn register_search_mount_ref(&self, mounted: Rc) { + let mut search_mount = self.search_mount; + search_mount.set(Some(mounted)); + } +} + +/// Create a value-agnostic combobox interaction store. +pub fn use_combobox(options: UseComboboxOptions) -> ComboboxStore { + let disclosure = use_disclosure(UseDisclosureOptions { + opened: options.opened, + default_opened: options.default_opened, + on_opened_change: options.on_opened_change, + }); + let options_signal = use_signal(Vec::new); + let highlighted_index = use_signal(|| None); + let submitted_option = use_signal(|| None); + let target_mount = use_signal(|| None); + let search_mount = use_signal(|| None); + + ComboboxStore { + disclosure, + options: options_signal, + highlighted_index, + submitted_option, + target_mount, + search_mount, + on_dropdown_open: options.on_dropdown_open, + on_dropdown_close: options.on_dropdown_close, + loop_navigation: options.loop_navigation, + } +} + +/// Create a combobox store for virtualized listbox usage. +/// +/// Virtualization is handled by [`VirtualizedComboboxOptions`](crate::combobox::VirtualizedComboboxOptions); +/// this hook keeps the same value-agnostic interaction surface as [`use_combobox`]. +pub fn use_virtualized_combobox( + options: UseVirtualizedComboboxOptions, +) -> VirtualizedComboboxStore { + use_combobox(options.combobox) +} + +fn focus_mounted(mount: Signal>>) { + if let Some(md) = mount() { + spawn(async move { + let _ = md.set_focus(true).await; + }); + } +} + +fn sync_combobox_option(options: &mut Vec, option: ComboboxOptionState) { + if let Some(position) = options.iter().position(|item| item.key.id == option.key.id) { + if options[position].key.index == option.key.index { + options[position] = option; + } else { + options.remove(position); + insert_combobox_option(options, option); + } + } else { + insert_combobox_option(options, option); + } +} + +fn insert_combobox_option(options: &mut Vec, option: ComboboxOptionState) { + let insert_at = options.partition_point(|item| item.key.index <= option.key.index); + options.insert(insert_at, option); +} + +fn has_enabled_visible_index(options: &[ComboboxOptionState], index: usize) -> bool { + options + .iter() + .any(|option| option.key.index == index && option.enabled_visible()) +} + +fn first_enabled_visible(options: &[ComboboxOptionState]) -> Option { + options + .iter() + .find(|option| option.enabled_visible()) + .map(|option| option.key.clone()) +} + +fn last_enabled_visible(options: &[ComboboxOptionState]) -> Option { + options + .iter() + .rev() + .find(|option| option.enabled_visible()) + .map(|option| option.key.clone()) +} + +fn next_enabled_visible( + options: &[ComboboxOptionState], + current: Option, + loop_navigation: bool, +) -> Option { + match current { + Some(current) => options + .iter() + .find(|option| option.key.index > current && option.enabled_visible()) + .or_else(|| { + loop_navigation + .then(|| options.iter().find(|option| option.enabled_visible())) + .flatten() + }), + None => options.iter().find(|option| option.enabled_visible()), + } + .map(|option| option.key.clone()) +} + +fn previous_enabled_visible( + options: &[ComboboxOptionState], + current: Option, + loop_navigation: bool, +) -> Option { + match current { + Some(current) => options + .iter() + .rev() + .find(|option| option.key.index < current && option.enabled_visible()) + .or_else(|| { + loop_navigation + .then(|| options.iter().rev().find(|option| option.enabled_visible())) + .flatten() + }), + None if loop_navigation => options.iter().rev().find(|option| option.enabled_visible()), + None => options.iter().find(|option| option.enabled_visible()), + } + .map(|option| option.key.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn option(id: &str, index: usize) -> ComboboxOptionState { + ComboboxOptionState { + key: ComboboxOptionKey { + id: id.to_string(), + index, + }, + disabled: false, + visible: true, + active: false, + } + } + + #[test] + fn sync_combobox_option_keeps_index_order() { + let mut options = vec![option("a", 0), option("c", 2)]; + + sync_combobox_option(&mut options, option("b", 1)); + + let ids: Vec<_> = options + .iter() + .map(|option| option.key.id.as_str()) + .collect(); + assert_eq!(ids, ["a", "b", "c"]); + } + + #[test] + fn navigation_skips_disabled_and_invisible_options() { + let options = vec![ + option("a", 0), + ComboboxOptionState { + disabled: true, + ..option("b", 1) + }, + ComboboxOptionState { + visible: false, + ..option("c", 2) + }, + option("d", 3), + ]; + + assert_eq!( + next_enabled_visible(&options, Some(0), true).unwrap().id, + "d" + ); + assert_eq!( + previous_enabled_visible(&options, Some(3), true) + .unwrap() + .id, + "a" + ); + } + + #[test] + fn navigation_respects_loop_navigation_setting() { + let options = vec![option("a", 0), option("b", 1)]; + + assert_eq!( + next_enabled_visible(&options, Some(1), true).unwrap().id, + "a" + ); + assert!(next_enabled_visible(&options, Some(1), false).is_none()); + + assert_eq!( + previous_enabled_visible(&options, Some(0), true) + .unwrap() + .id, + "b" + ); + assert!(previous_enabled_visible(&options, Some(0), false).is_none()); + } + + #[test] + fn navigation_without_current_uses_first_or_last_consistently() { + let options = vec![option("a", 0), option("b", 1)]; + + assert_eq!(next_enabled_visible(&options, None, true).unwrap().id, "a"); + assert_eq!( + previous_enabled_visible(&options, None, true).unwrap().id, + "b" + ); + assert_eq!( + previous_enabled_visible(&options, None, false).unwrap().id, + "a" + ); + } + + #[test] + fn first_last_and_active_skip_unselectable_options() { + let options = vec![ + ComboboxOptionState { + disabled: true, + active: true, + ..option("a", 0) + }, + ComboboxOptionState { + visible: false, + active: true, + ..option("b", 1) + }, + option("c", 2), + ]; + + assert_eq!(first_enabled_visible(&options).unwrap().id, "c"); + assert_eq!(last_enabled_visible(&options).unwrap().id, "c"); + } +} diff --git a/primitives/src/combobox/mod.rs b/primitives/src/combobox/mod.rs index 7ee7e7b25..6acccc992 100644 --- a/primitives/src/combobox/mod.rs +++ b/primitives/src/combobox/mod.rs @@ -1,15 +1,30 @@ //! Autocomplete input with a filterable popup list. //! //! `ComboboxInput` is the text input and trigger. `ComboboxList` contains -//! `ComboboxOption` children. +//! `ComboboxOption` children. Split anatomy is available through +//! `ComboboxTarget`, `ComboboxEventsTarget`, `ComboboxDropdownTarget`, +//! `ComboboxSearch`, and `ComboboxOptions`. mod components; mod context; +mod hook; pub use components::{ - Combobox, ComboboxEmpty, ComboboxEmptyProps, ComboboxInput, ComboboxInputProps, - ComboboxItemIndicator, ComboboxItemIndicatorProps, ComboboxList, ComboboxListProps, - ComboboxOption, ComboboxOptionProps, ComboboxProps, + use_combobox_dropdown_target_attributes, use_combobox_events_target_attributes, + use_combobox_search_attributes, use_combobox_target_attributes, Autocomplete, + AutocompleteProps, Combobox, ComboboxDropdownTarget, ComboboxDropdownTargetProps, + ComboboxEmpty, ComboboxEmptyProps, ComboboxEventsTarget, ComboboxEventsTargetProps, + ComboboxInput, ComboboxInputProps, ComboboxItemIndicator, ComboboxItemIndicatorProps, + ComboboxList, ComboboxListProps, ComboboxOption, ComboboxOptionProps, ComboboxOptions, + ComboboxOptionsProps, ComboboxProps, ComboboxSearch, ComboboxSearchProps, ComboboxTarget, + ComboboxTargetProps, MultiSelect, MultiSelectProps, Pill, PillProps, PillsInput, + PillsInputProps, TagsInput, TagsInputProps, VirtualizedComboboxOptions, + VirtualizedComboboxOptionsProps, }; pub use context::default_combobox_filter; +pub use hook::{ + use_combobox, use_virtualized_combobox, ComboboxDropdownEventSource, ComboboxIndexTarget, + ComboboxOptionKey, ComboboxStore, ComboboxSubmittedOption, UseComboboxOptions, + UseVirtualizedComboboxOptions, VirtualizedComboboxStore, +}; diff --git a/primitives/src/disclosure.rs b/primitives/src/disclosure.rs new file mode 100644 index 000000000..6d5af0ad1 --- /dev/null +++ b/primitives/src/disclosure.rs @@ -0,0 +1,90 @@ +//! Controlled or uncontrolled open/closed state. + +use dioxus::prelude::*; + +use crate::use_controlled; + +/// Options for [`use_disclosure`]. +#[derive(Clone, Copy)] +pub struct UseDisclosureOptions { + /// Controlled open state. When set, the returned state follows this value. + pub opened: ReadSignal>, + /// Initial open state when uncontrolled. + pub default_opened: ReadSignal, + /// Callback fired when the open state changes. + pub on_opened_change: Callback, +} + +impl Default for UseDisclosureOptions { + fn default() -> Self { + Self { + opened: ReadSignal::new(Signal::new(None)), + default_opened: ReadSignal::new(Signal::new(false)), + on_opened_change: Callback::default(), + } + } +} + +/// A cloneable handle for disclosure state. +#[derive(Clone, Copy)] +pub struct Disclosure { + opened: Memo, + set_opened: Callback, +} + +impl Disclosure { + /// Returns whether the disclosure is currently open. + pub fn opened(&self) -> bool { + (self.opened)() + } + + /// Opens the disclosure. + /// + /// Returns `true` when this call requested an actual closed-to-open + /// transition. + pub fn open(&self) -> bool { + self.set_opened_if_changed(true) + } + + /// Closes the disclosure. + /// + /// Returns `true` when this call requested an actual open-to-closed + /// transition. + pub fn close(&self) -> bool { + self.set_opened_if_changed(false) + } + + /// Toggles the disclosure. + /// + /// Returns the next open state when this call requested a transition. + pub fn toggle(&self) -> Option { + let next = !self.opened(); + self.set_opened_if_changed(next).then_some(next) + } + + /// Sets the disclosure open state. + /// + /// Returns `true` when the requested value differs from the current value. + pub fn set_opened(&self, opened: bool) -> bool { + self.set_opened_if_changed(opened) + } + + fn set_opened_if_changed(&self, opened: bool) -> bool { + if self.opened() == opened { + return false; + } + self.set_opened.call(opened); + true + } +} + +/// Create controlled or uncontrolled disclosure state with transition helpers. +pub fn use_disclosure(options: UseDisclosureOptions) -> Disclosure { + let (opened, set_opened) = use_controlled( + options.opened, + options.default_opened.cloned(), + options.on_opened_change, + ); + + Disclosure { opened, set_opened } +} diff --git a/primitives/src/lib.rs b/primitives/src/lib.rs index facfc1e63..603278333 100644 --- a/primitives/src/lib.rs +++ b/primitives/src/lib.rs @@ -25,6 +25,7 @@ pub mod combobox; pub mod context_menu; pub mod date_picker; pub mod dialog; +pub mod disclosure; pub mod drag_and_drop_list; pub mod dropdown_menu; mod focus; diff --git a/primitives/src/listbox.rs b/primitives/src/listbox.rs index 6ef68db4c..a6db30511 100644 --- a/primitives/src/listbox.rs +++ b/primitives/src/listbox.rs @@ -47,10 +47,18 @@ pub(crate) fn use_listbox_render( pub(crate) fn use_listbox_container( id: ReadSignal>, - mut selectable: SelectableContext, + selectable: SelectableContext, ) -> ListboxState { let id = use_listbox_id(id, selectable.list_id); - let render = use_listbox_render(id, selectable.open); + use_listbox_container_with_open(id, selectable, selectable.open) +} + +pub(crate) fn use_listbox_container_with_open( + id: Memo, + mut selectable: SelectableContext, + open: impl Readable + Copy + 'static, +) -> ListboxState { + let render = use_listbox_render(id, open); use_context_provider(|| ListboxContext { render: render.into(), diff --git a/primitives/src/selectable.rs b/primitives/src/selectable.rs index 51eecf5c5..90758b497 100644 --- a/primitives/src/selectable.rs +++ b/primitives/src/selectable.rs @@ -80,6 +80,10 @@ impl SelectableContext { selection::selected_text(values.iter(), &options) } + pub(crate) fn list_id(&self) -> Option { + (self.list_id)() + } + pub(crate) fn is_selected(&self, value: &RcPartialEqValue) -> bool { self.values.read().iter().any(|selected| selected == value) } @@ -209,11 +213,58 @@ pub(crate) fn use_selectable_root( open: Controlled, ) -> SelectableContext { let (open, set_open) = use_controlled(open.value, open.default.cloned(), open.on_change); - let options: Signal> = use_signal(Vec::default); - let list_id = use_signal(|| None); let focus_state = use_focus_provider(roving_loop); let initial_focus = use_signal(|| None); + build_selectable_context( + open, + set_open, + values, + set_value, + selection_mode, + disabled, + focus_state, + initial_focus, + ) +} + +pub(crate) fn use_selectable_root_with_state( + values: Memo>, + set_value: Callback, + selection_mode: SelectionMode, + disabled: ReadSignal, + roving_loop: ReadSignal, + open: Memo, + set_open: Callback, +) -> SelectableContext { + let focus_state = use_focus_provider(roving_loop); + let initial_focus = use_signal(|| None); + + build_selectable_context( + open, + set_open, + values, + set_value, + selection_mode, + disabled, + focus_state, + initial_focus, + ) +} + +fn build_selectable_context( + open: Memo, + set_open: Callback, + values: Memo>, + set_value: Callback, + selection_mode: SelectionMode, + disabled: ReadSignal, + focus_state: FocusState, + initial_focus: Signal>, +) -> SelectableContext { + let options: Signal> = use_signal(Vec::default); + let list_id = use_signal(|| None); + SelectableContext { open, set_open, From ab702199c3ead884fed5f6b8e42dd589d6da2008 Mon Sep 17 00:00:00 2001 From: Saren Date: Tue, 26 May 2026 19:04:42 -0700 Subject: [PATCH 3/8] feat(preview): add combobox variants --- playwright/combobox.spec.ts | 23 +- preview/src/components/combobox/component.rs | 277 +++++++++++++++++- preview/src/components/combobox/docs.md | 46 ++- preview/src/components/combobox/style.css | 135 ++++++++- .../combobox/variants/autocomplete/mod.rs | 38 +++ .../combobox/variants/dynamic/mod.rs | 26 +- .../combobox/variants/multi_select/mod.rs | 38 +++ .../combobox/variants/tags_input/mod.rs | 20 ++ .../combobox/variants/virtualized/mod.rs | 43 +++ preview/src/components/mod.rs | 2 +- 10 files changed, 618 insertions(+), 30 deletions(-) create mode 100644 preview/src/components/combobox/variants/autocomplete/mod.rs create mode 100644 preview/src/components/combobox/variants/multi_select/mod.rs create mode 100644 preview/src/components/combobox/variants/tags_input/mod.rs create mode 100644 preview/src/components/combobox/variants/virtualized/mod.rs diff --git a/playwright/combobox.spec.ts b/playwright/combobox.spec.ts index b773c4f0b..e912d76d4 100644 --- a/playwright/combobox.spec.ts +++ b/playwright/combobox.spec.ts @@ -3,6 +3,8 @@ import { test, expect, devices, type Page } from "@playwright/test"; const URL = "http://127.0.0.1:8080/component/?name=combobox&"; const variantUrl = (variant: string) => `http://127.0.0.1:8080/component/?name=combobox&variant=${variant}&`; +const blockVariantUrl = (variant: string) => + `http://127.0.0.1:8080/component/block/?name=combobox&variant=${variant}&`; const input = (page: Page) => page.getByRole("combobox", { name: "Select framework" }); @@ -230,6 +232,7 @@ test("dynamic option removal updates filtering and keyboard selection", async ({ await page.waitForLoadState('networkidle'); const trigger = page.getByRole("combobox", { name: "Dynamic framework" }); + const toggleSvelte = page.getByRole("button", { name: "Toggle SvelteKit" }); await trigger.click(); await page.keyboard.type("s"); @@ -246,12 +249,13 @@ test("dynamic option removal updates filtering and keyboard selection", async ({ "true", ); - await page.getByRole("button", { name: "Toggle SvelteKit" }).click(); + await expect(trigger).toBeFocused(); + await toggleSvelte.click(); await expect(list(page).getByRole("option", { name: "SvelteKit" })).toHaveCount(0); await expect(list(page).getByRole("option", { name: "SolidStart" })).toBeVisible(); - - await trigger.click(); + await expect(content(page)).toBeVisible(); await expect(trigger).toBeFocused(); + await page.keyboard.press("ArrowDown"); const next = list(page).getByRole("option", { name: "Next.js" }); await expect(next).toHaveAttribute("data-highlighted", "true"); @@ -261,6 +265,19 @@ test("dynamic option removal updates filtering and keyboard selection", async ({ await expect(trigger).toHaveValue("Next.js"); }); +test("virtualized variant shows visible options when opened", async ({ page }) => { + await page.goto(blockVariantUrl("virtualized"), { timeout: 20 * 60 * 1000 }); + await page.waitForLoadState('networkidle'); + + const trigger = page.getByRole("combobox", { name: "Virtualized option picker" }); + await trigger.click(); + + const menu = list(page); + await expect(menu).toBeVisible(); + await expect(menu.getByRole("option", { name: "Option 0" })).toBeVisible(); + await expect(menu.getByRole("option", { name: "Option 1" })).toBeVisible(); +}); + test("touch selection commits and closes", async ({ browser, browserName }) => { test.skip(browserName === "firefox", "Firefox does not support mobile contexts"); diff --git a/preview/src/components/combobox/component.rs b/preview/src/components/combobox/component.rs index fe1fa9699..5f947721b 100644 --- a/preview/src/components/combobox/component.rs +++ b/preview/src/components/combobox/component.rs @@ -1,7 +1,8 @@ use dioxus::prelude::*; use dioxus_icons::lucide::{Check, ChevronsUpDown}; use dioxus_primitives::combobox::{ - self, default_combobox_filter, ComboboxEmptyProps, ComboboxOptionProps, + self, default_combobox_filter, AutocompleteProps, ComboboxEmptyProps, ComboboxOptionProps, + MultiSelectProps, PillProps, PillsInputProps, TagsInputProps, VirtualizedComboboxOptionsProps, }; use dioxus_primitives::{dioxus_attributes::attributes, merge_attributes}; @@ -61,9 +62,81 @@ pub struct ComboboxProps { pub children: Element, } +#[derive(Props, Clone, PartialEq)] +pub struct VirtualizedComboboxProps { + #[props(default)] + pub value: Option>>, + + #[props(default)] + pub default_value: Option, + + #[props(default)] + pub on_value_change: Callback>, + + #[props(default)] + pub disabled: ReadSignal, + + #[props(default)] + pub open: ReadSignal>, + + #[props(default)] + pub default_open: ReadSignal, + + #[props(default)] + pub on_open_change: Callback, + + #[props(default)] + pub query: ReadSignal>, + + #[props(default)] + pub default_query: ReadSignal, + + #[props(default)] + pub on_query_change: Callback, + + #[props(default = ReadSignal::new(Signal::new(true)))] + pub roving_loop: ReadSignal, + + #[props(default = Callback::new(|(q, t): (String, String)| default_combobox_filter(&q, &t)))] + pub filter: Callback<(String, String), bool>, + + #[props(default)] + pub placeholder: ReadSignal, + + #[props(default)] + pub aria_label: Option, + + #[props(default)] + pub list_aria_label: Option, + + /// The total number of source options before any virtualized visibility mapping is applied. + pub count: ReadSignal, + + #[props(default = ReadSignal::new(Signal::new(8)))] + pub buffer: ReadSignal, + + /// Optional visible-row to source-option index mapping for virtualized filtering. + /// + /// When provided, only these absolute option indices are virtualized and rendered. + #[props(default)] + pub visible_indices: Option>>, + + pub estimate_size: Option>, + + pub render_option: Callback, + + #[props(default)] + pub list_id: ReadSignal>, + + #[props(extends = GlobalAttributes)] + pub attributes: Vec, +} + #[component] pub fn Combobox(props: ComboboxProps) -> Element { - let base = attributes!(div { class: Styles::dx_combobox }); + let base = attributes!(div { + class: Styles::dx_combobox + }); let merged = merge_attributes(vec![base, props.attributes]); rsx! { @@ -81,8 +154,9 @@ pub fn Combobox(props: ComboboxProps) -> Elem roving_loop: props.roving_loop, filter: props.filter, attributes: merged, - div { class: Styles::dx_combobox_input_wrapper, - combobox::ComboboxInput { + combobox::ComboboxTarget { + class: Styles::dx_combobox_input_wrapper, + combobox::ComboboxSearch { class: Styles::dx_combobox_input, placeholder: props.placeholder, aria_label: props.aria_label.clone(), @@ -92,18 +166,199 @@ pub fn Combobox(props: ComboboxProps) -> Elem size: "16px", } } - combobox::ComboboxList { - class: Styles::dx_combobox_list, - aria_label: props.list_aria_label.clone(), - {props.children} + combobox::ComboboxDropdownTarget { + combobox::ComboboxOptions { + class: Styles::dx_combobox_list, + aria_label: props.list_aria_label.clone(), + {props.children} + } } } } } +#[component] +pub fn VirtualizedCombobox( + props: VirtualizedComboboxProps, +) -> Element { + let base = attributes!(div { + class: Styles::dx_combobox + }); + let merged = merge_attributes(vec![base, props.attributes]); + + rsx! { + combobox::Combobox { + value: props.value, + default_value: props.default_value, + on_value_change: props.on_value_change, + disabled: props.disabled, + open: props.open, + default_open: props.default_open, + on_open_change: props.on_open_change, + query: props.query, + default_query: props.default_query, + on_query_change: props.on_query_change, + roving_loop: props.roving_loop, + filter: props.filter, + attributes: merged, + combobox::ComboboxTarget { + class: Styles::dx_combobox_input_wrapper, + combobox::ComboboxSearch { + class: Styles::dx_combobox_input, + placeholder: props.placeholder, + aria_label: props.aria_label.clone(), + } + ChevronsUpDown { + class: Styles::dx_combobox_expand_icon, + size: "16px", + } + } + combobox::ComboboxDropdownTarget { + combobox::VirtualizedComboboxOptions { + class: Styles::dx_combobox_list, + aria_label: props.list_aria_label.clone(), + count: props.count, + visible_indices: props.visible_indices, + buffer: props.buffer, + estimate_size: props.estimate_size, + render_option: props.render_option, + id: props.list_id, + } + } + } + } +} + +#[component] +pub fn Autocomplete(props: AutocompleteProps) -> Element { + let base = attributes!(div { + class: Styles::dx_combobox + }); + let merged = merge_attributes(vec![base, props.attributes]); + + rsx! { + combobox::Autocomplete { + value: props.value, + default_value: props.default_value, + on_value_change: props.on_value_change, + disabled: props.disabled, + open: props.open, + default_open: props.default_open, + on_open_change: props.on_open_change, + query: props.query, + default_query: props.default_query, + on_query_change: props.on_query_change, + roving_loop: props.roving_loop, + filter: props.filter, + placeholder: props.placeholder, + attributes: merged, + {props.children} + } + } +} + +#[component] +pub fn MultiSelect(props: MultiSelectProps) -> Element { + let base = attributes!(div { + class: Styles::dx_combobox + }); + let merged = merge_attributes(vec![base, props.attributes]); + + rsx! { + combobox::MultiSelect:: { + values: props.values, + default_values: props.default_values, + on_values_change: props.on_values_change, + disabled: props.disabled, + open: props.open, + default_open: props.default_open, + on_open_change: props.on_open_change, + query: props.query, + default_query: props.default_query, + on_query_change: props.on_query_change, + roving_loop: props.roving_loop, + filter: props.filter, + placeholder: props.placeholder, + max_values: props.max_values, + render_value: props.render_value, + attributes: merged, + {props.children} + } + } +} + +#[component] +pub fn PillsInput(props: PillsInputProps) -> Element { + let base = attributes!(div { + class: Styles::dx_combobox_input + }); + let merged = merge_attributes(vec![base, props.attributes]); + + rsx! { + combobox::PillsInput { + disabled: props.disabled, + attributes: merged, + {props.children} + } + } +} + +#[component] +pub fn Pill(props: PillProps) -> Element { + rsx! { + combobox::Pill { + on_remove: props.on_remove, + attributes: props.attributes, + {props.children} + } + } +} + +#[component] +pub fn TagsInput(props: TagsInputProps) -> Element { + let base = attributes!(div { + class: Styles::dx_combobox + }); + let merged = merge_attributes(vec![base, props.attributes]); + + rsx! { + combobox::TagsInput { + values: props.values, + default_values: props.default_values, + on_values_change: props.on_values_change, + placeholder: props.placeholder, + allow_duplicates: props.allow_duplicates, + disabled: props.disabled, + attributes: merged, + } + } +} + +#[component] +pub fn VirtualizedComboboxOptions(props: VirtualizedComboboxOptionsProps) -> Element { + let base = attributes!(div { + class: Styles::dx_combobox_list + }); + let merged = merge_attributes(vec![base, props.attributes]); + + rsx! { + combobox::VirtualizedComboboxOptions { + count: props.count, + visible_indices: props.visible_indices, + buffer: props.buffer, + estimate_size: props.estimate_size, + render_option: props.render_option, + id: props.id, + attributes: merged, + } + } +} + #[component] pub fn ComboboxEmpty(props: ComboboxEmptyProps) -> Element { - let base = attributes!(div { class: Styles::dx_combobox_empty }); + let base = attributes!(div { + class: Styles::dx_combobox_empty + }); let merged = merge_attributes(vec![base, props.attributes]); rsx! { @@ -116,7 +371,9 @@ pub fn ComboboxEmpty(props: ComboboxEmptyProps) -> Element { #[component] pub fn ComboboxOption(props: ComboboxOptionProps) -> Element { - let base = attributes!(div { class: Styles::dx_combobox_option }); + let base = attributes!(div { + class: Styles::dx_combobox_option + }); let merged = merge_attributes(vec![base, props.attributes]); rsx! { diff --git a/preview/src/components/combobox/docs.md b/preview/src/components/combobox/docs.md index 6a5fed978..affc98786 100644 --- a/preview/src/components/combobox/docs.md +++ b/preview/src/components/combobox/docs.md @@ -1,9 +1,19 @@ -The Combobox component is an autocomplete input with a filterable popup list. +The Combobox family provides reusable listbox/search interactions for autocomplete-style inputs. +The low-level `Combobox` owns dropdown, highlight, option registry, and submit interaction state; +higher-level components own their own value and query models. Filtering preserves the order defined by the rendered `ComboboxOption` elements and their `index` props. If you want query-dependent ranking, control `query`, sort your item data in user code, render the options in that sorted order, and assign indexes from the sorted list. +## Variants + +- `Combobox` is the low-level selectable autocomplete surface. +- `Autocomplete` owns string input value and maps option submit to the input label. +- `MultiSelect` owns a selected-value array, search query, max-values, and selected pills. +- `TagsInput` owns tag parsing and removable pills. +- `VirtualizedComboboxOptions` renders a listbox with only the visible option window while preserving `ComboboxOption` ids and indexes. + ## Component Structure ```rust @@ -29,3 +39,37 @@ Combobox:: { } } ``` + +## MultiSelect + +```rust +MultiSelect:: { + default_values: vec!["mushroom".to_string()], + max_values: 3usize, + render_value: |value: String| rsx! { "{value}" }, + placeholder: "Pick toppings...", + ComboboxOption:: { + index: 0usize, + value: "mushroom".to_string(), + text_value: "Mushroom", + "Mushroom" + } +} +``` + +## Virtualized Options + +```rust +VirtualizedCombobox:: { + count: 1000usize, + estimate_size: |_: usize| 36, + render_option: |index: usize| rsx! { + ComboboxOption:: { + index, + value: format!("option-{index}"), + text_value: format!("Option {index}"), + "Option {index}" + } + } +} +``` diff --git a/preview/src/components/combobox/style.css b/preview/src/components/combobox/style.css index 86734e702..3608ed55b 100644 --- a/preview/src/components/combobox/style.css +++ b/preview/src/components/combobox/style.css @@ -3,12 +3,36 @@ display: inline-block; } -.dx-combobox-input-wrapper { +.dx-combobox-input-wrapper, +.dx-combobox [data-combobox-target] { position: relative; width: 200px; } -.dx-combobox-input { +.dx-combobox[data-pills-input], +.dx-combobox [data-pills-input] { + display: flex; + flex-wrap: wrap; + align-items: center; + width: 200px; + min-height: 2.25rem; + box-sizing: border-box; + padding: 0.25rem; + border-radius: 0.5rem; + background: var(--light, var(--primary-color)) var(--dark, var(--primary-color-3)); + box-shadow: inset 0 0 0 1px var(--light, var(--primary-color-6)) var(--dark, var(--primary-color-7)); + gap: 0.25rem; +} + +.dx-combobox[data-pills-input]:focus-within, +.dx-combobox[data-pills-input][data-state="open"], +.dx-combobox [data-pills-input]:focus-within, +.dx-combobox [data-pills-input][data-state="open"] { + background: var(--light, var(--primary-color-4)) var(--dark, var(--primary-color-5)); +} + +.dx-combobox-input, +.dx-combobox [data-combobox-search] { width: 100%; height: 2.25rem; box-sizing: border-box; @@ -25,18 +49,57 @@ outline: none; } +.dx-combobox[data-pills-input] [data-combobox-search], +.dx-combobox[data-pills-input] [data-pills-input-field], +.dx-combobox [data-pills-input] [data-combobox-search], +.dx-combobox [data-pills-input] [data-pills-input-field] { + appearance: none; + -webkit-appearance: none; + flex: 1 1 6rem; + width: auto; + min-width: 5rem; + height: 1.5rem; + padding: 0 0.25rem; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + color: var(--secondary-color-1); + outline: 0; +} + +.dx-combobox[data-pills-input] [data-combobox-search]:focus-visible, +.dx-combobox[data-pills-input] [data-combobox-search][data-state="open"], +.dx-combobox[data-pills-input] [data-pills-input-field]:focus-visible, +.dx-combobox [data-pills-input] [data-combobox-search]:focus-visible, +.dx-combobox [data-pills-input] [data-combobox-search][data-state="open"], +.dx-combobox [data-pills-input] [data-pills-input-field]:focus-visible { + background: transparent; + box-shadow: none; + outline: 0; +} + +.dx-combobox[data-pills-input] [data-pills-input-field]::placeholder, +.dx-combobox [data-pills-input] [data-pills-input-field]::placeholder { + color: var(--secondary-color-5); +} + .dx-combobox-input:hover:not([disabled]), .dx-combobox-input:focus-visible, -.dx-combobox-input[data-state="open"] { +.dx-combobox-input[data-state="open"], +.dx-combobox [data-combobox-search]:focus-visible, +.dx-combobox [data-combobox-search][data-state="open"] { background: var(--light, var(--primary-color-4)) var(--dark, var(--primary-color-5)); } -.dx-combobox-input[disabled] { +.dx-combobox-input[disabled], +.dx-combobox [data-combobox-search][disabled] { cursor: not-allowed; opacity: 0.5; } -.dx-combobox-input::placeholder { +.dx-combobox-input::placeholder, +.dx-combobox [data-combobox-search]::placeholder { color: var(--secondary-color-5); } @@ -49,7 +112,8 @@ transform: translateY(-50%); } -.dx-combobox-list { +.dx-combobox-list, +.dx-combobox [role="listbox"] { position: absolute; z-index: 50; top: calc(100% + 0.25rem); @@ -67,11 +131,13 @@ transform-origin: top; } -.dx-combobox-list[data-state="open"] { +.dx-combobox-list[data-state="open"], +.dx-combobox [role="listbox"][data-state="open"] { animation: dx-picker-in 150ms ease-out forwards; } -.dx-combobox-list[data-state="closed"] { +.dx-combobox-list[data-state="closed"], +.dx-combobox [role="listbox"][data-state="closed"] { animation: dx-picker-out 100ms ease-in forwards; pointer-events: none; } @@ -106,7 +172,8 @@ text-align: center; } -.dx-combobox-option { +.dx-combobox-option, +.dx-combobox [role="option"] { display: flex; align-items: center; padding: 0.375rem 0.5rem; @@ -119,12 +186,14 @@ user-select: none; } -.dx-combobox-option[data-highlighted="true"] { +.dx-combobox-option[data-highlighted="true"], +.dx-combobox [role="option"][data-highlighted="true"] { background: var(--light, var(--primary-color-4)) var(--dark, var(--primary-color-7)); color: var(--secondary-color-1); } -.dx-combobox-option[data-disabled="true"] { +.dx-combobox-option[data-disabled="true"], +.dx-combobox [role="option"][data-disabled="true"] { cursor: not-allowed; opacity: 0.5; pointer-events: none; @@ -134,3 +203,47 @@ margin-left: auto; color: var(--secondary-color-5); } + +.dx-combobox [data-pill], +[data-pills-input] [data-pill] { + display: inline-flex; + align-items: center; + max-width: 100%; + height: 1.5rem; + box-sizing: border-box; + padding: 0 0.25rem 0 0.5rem; + border-radius: 0.375rem; + background: var(--light, var(--primary-color-4)) var(--dark, var(--primary-color-6)); + color: var(--secondary-color-1); + font-size: 0.75rem; + gap: 0.25rem; + line-height: 1rem; +} + +.dx-combobox [data-pill] button, +[data-pills-input] [data-pill] button { + display: inline-grid; + width: 1rem; + height: 1rem; + place-items: center; + padding: 0; + border: none; + border-radius: 0.25rem; + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; +} + +.dx-combobox-demo-stack { + display: grid; + max-width: 24rem; + gap: 0.75rem; +} + +.dx-combobox-demo-value { + margin: 0; + color: var(--secondary-color-5); + font-size: 0.875rem; + line-height: 1.25rem; +} diff --git a/preview/src/components/combobox/variants/autocomplete/mod.rs b/preview/src/components/combobox/variants/autocomplete/mod.rs new file mode 100644 index 000000000..b37389a7c --- /dev/null +++ b/preview/src/components/combobox/variants/autocomplete/mod.rs @@ -0,0 +1,38 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + let mut value = use_signal(|| None::); + let frameworks: &[(&str, &str)] = &[ + ("next", "Next.js"), + ("svelte", "SvelteKit"), + ("nuxt", "Nuxt.js"), + ("remix", "Remix"), + ("astro", "Astro"), + ("solid", "SolidStart"), + ("dioxus", "Dioxus"), + ]; + + rsx! { + div { class: "dx-combobox-demo-stack", + Autocomplete { + value: Some(value.into()), + on_value_change: move |next| value.set(next), + placeholder: "Type a framework...", + ComboboxEmpty { "No framework found." } + for (index, (value, label)) in frameworks.iter().enumerate() { + ComboboxOption:: { + index, + value: value.to_string(), + text_value: label.to_string(), + {*label} + } + } + } + p { class: "dx-combobox-demo-value", + "Selected: {value().unwrap_or_else(|| \"none\".to_string())}" + } + } + } +} diff --git a/preview/src/components/combobox/variants/dynamic/mod.rs b/preview/src/components/combobox/variants/dynamic/mod.rs index 35d7d892e..5d80fbdab 100644 --- a/preview/src/components/combobox/variants/dynamic/mod.rs +++ b/preview/src/components/combobox/variants/dynamic/mod.rs @@ -11,14 +11,32 @@ pub fn Demo() -> Element { div { style: "display: flex; gap: 0.5rem;", button { r#type: "button", - onpointerdown: move |event| event.prevent_default(), - onclick: move |_| show_svelte.toggle(), + onpointerdown: move |event| { + event.prevent_default(); + show_svelte.toggle(); + }, + onkeydown: move |event| { + let key = event.key(); + if matches!(key, Key::Enter) || matches!(key, Key::Character(ch) if ch == " ") { + event.prevent_default(); + show_svelte.toggle(); + } + }, "Toggle SvelteKit" } button { r#type: "button", - onpointerdown: move |event| event.prevent_default(), - onclick: move |_| show_solid.toggle(), + onpointerdown: move |event| { + event.prevent_default(); + show_solid.toggle(); + }, + onkeydown: move |event| { + let key = event.key(); + if matches!(key, Key::Enter) || matches!(key, Key::Character(ch) if ch == " ") { + event.prevent_default(); + show_solid.toggle(); + } + }, "Toggle SolidStart" } } diff --git a/preview/src/components/combobox/variants/multi_select/mod.rs b/preview/src/components/combobox/variants/multi_select/mod.rs new file mode 100644 index 000000000..babe625a4 --- /dev/null +++ b/preview/src/components/combobox/variants/multi_select/mod.rs @@ -0,0 +1,38 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + let mut values = use_signal(|| Some(vec!["mushroom".to_string()])); + let toppings: &[(&str, &str)] = &[ + ("pepperoni", "Pepperoni"), + ("mushroom", "Mushroom"), + ("onion", "Onion"), + ("olive", "Olive"), + ("jalapeno", "Jalapeno"), + ]; + + rsx! { + div { class: "dx-combobox-demo-stack", + MultiSelect:: { + values, + on_values_change: move |next| values.set(Some(next)), + max_values: 3usize, + render_value: |value: String| rsx! { "{value}" }, + placeholder: "Pick toppings...", + ComboboxEmpty { "No toppings found." } + for (index, (value, label)) in toppings.iter().enumerate() { + ComboboxOption:: { + index, + value: value.to_string(), + text_value: label.to_string(), + {*label} + } + } + } + p { class: "dx-combobox-demo-value", + "Selected: {values().unwrap_or_default().join(\", \")}" + } + } + } +} diff --git a/preview/src/components/combobox/variants/tags_input/mod.rs b/preview/src/components/combobox/variants/tags_input/mod.rs new file mode 100644 index 000000000..61e12b9eb --- /dev/null +++ b/preview/src/components/combobox/variants/tags_input/mod.rs @@ -0,0 +1,20 @@ +use super::super::component::*; +use dioxus::prelude::*; + +#[component] +pub fn Demo() -> Element { + let mut values = use_signal(|| Some(vec!["dioxus".to_string(), "components".to_string()])); + + rsx! { + div { class: "dx-combobox-demo-stack", + TagsInput { + values, + on_values_change: move |next| values.set(Some(next)), + placeholder: "Add tag and press Enter...", + } + p { class: "dx-combobox-demo-value", + "Tags: {values().unwrap_or_default().join(\", \")}" + } + } + } +} diff --git a/preview/src/components/combobox/variants/virtualized/mod.rs b/preview/src/components/combobox/variants/virtualized/mod.rs new file mode 100644 index 000000000..265bf6432 --- /dev/null +++ b/preview/src/components/combobox/variants/virtualized/mod.rs @@ -0,0 +1,43 @@ +use super::super::component::*; +use dioxus::prelude::*; +use dioxus_primitives::combobox::default_combobox_filter; + +#[component] +pub fn Demo() -> Element { + let mut value = use_signal(|| None::); + let mut query = use_signal(String::new); + let visible_indices = use_memo(move || { + let query = query.read().clone(); + (0..1000) + .filter(|index| default_combobox_filter(&query, &format!("Option {index}"))) + .collect::>() + }); + + rsx! { + div { class: "dx-combobox-demo-stack", + VirtualizedCombobox:: { + value: Some(value.into()), + on_value_change: move |next| value.set(next), + query: Some(query()), + on_query_change: move |next| query.set(next), + placeholder: "Search 1,000 options...", + aria_label: "Virtualized option picker", + list_aria_label: "Virtualized options", + count: 1000usize, + visible_indices: Some(visible_indices.into()), + estimate_size: |_: usize| 36, + render_option: |index: usize| rsx! { + ComboboxOption:: { + index, + value: format!("option-{index}"), + text_value: format!("Option {index}"), + "Option {index}" + } + }, + } + p { class: "dx-combobox-demo-value", + "Selected: {value().unwrap_or_else(|| \"none\".to_string())}" + } + } + } +} diff --git a/preview/src/components/mod.rs b/preview/src/components/mod.rs index cad937b4d..0ecf1f51c 100644 --- a/preview/src/components/mod.rs +++ b/preview/src/components/mod.rs @@ -181,7 +181,7 @@ examples!( checkbox, collapsible, color_picker, - combobox[controlled, disabled, dynamic], + combobox[controlled, disabled, dynamic, autocomplete, multi_select, tags_input, virtualized], context_menu, date_picker[internationalized, range, multi_month, unavailable_dates], dialog, From 1ddc0e07461d8899f84767f022ea6818369d5104 Mon Sep 17 00:00:00 2001 From: Saren Date: Tue, 26 May 2026 19:04:42 -0700 Subject: [PATCH 4/8] feat(primitives): add combobox spread handles --- docs/plans/use-combobox-architecture.md | 447 +++++++++++++++++ primitives/src/combobox/components/mod.rs | 11 +- primitives/src/combobox/components/target.rs | 490 +++++++++++++------ primitives/src/combobox/hook.rs | 19 +- primitives/src/combobox/mod.rs | 22 +- 5 files changed, 812 insertions(+), 177 deletions(-) create mode 100644 docs/plans/use-combobox-architecture.md diff --git a/docs/plans/use-combobox-architecture.md b/docs/plans/use-combobox-architecture.md new file mode 100644 index 000000000..e2d63c357 --- /dev/null +++ b/docs/plans/use-combobox-architecture.md @@ -0,0 +1,447 @@ +# use_combobox Architecture Plan + +## Goal + +Add a reusable `use_combobox()` primitive hook that can power higher-level components such as `Autocomplete`, `Select`, `MultiSelect`, `TagsInput`, and `PillsInput`. + +The hook should follow the spirit of Mantine's `useCombobox`: shared dropdown, focus, highlighted-option, and option-submit behavior lives in one store, while each higher-level component owns its own value and query model. + +## Source Model + +Mantine's `useCombobox()` provides a store with: + +- controlled or uncontrolled dropdown open state +- event-source-aware open, close, and toggle methods +- highlighted option index navigation +- first, active, next, previous, reset, update, and click selected option methods +- list id and target/search focus refs +- deferred focus and selected-index updates + +Mantine components then compose that store differently: + +- `Autocomplete` owns input text and maps option submit to setting the input label. +- Mantine `Select` owns a single selected value and search text. +- `MultiSelect` owns selected value arrays, pills, search text, and removal behavior. +- `TagsInput` owns tag parsing, tag arrays, pills, and search text. + +The key architectural point is that `useCombobox()` is the shared interaction engine, not the owner of every component's value state. + +## Local Constraints + +The local repo already has related primitive infrastructure: + +- `primitives/src/combobox/context.rs` contains `ComboboxContext`. +- `primitives/src/combobox/components/*` contains the current primitive components. +- `primitives/src/selectable.rs` owns generic selectable value behavior. +- `primitives/src/listbox.rs`, `focus.rs`, and `selection.rs` provide list/focus/selection concepts. +- `dioxus-components/src/components/combobox/*` contains styled wrappers. +- The current local multi-value select primitive is named `SelectMulti`; a separate `MultiSelect` component should still be added rather than treating `SelectMulti` as the final public multi-select surface. +- Local `Select`/`SelectMulti` currently use typeahead behavior rather than Mantine-style searchable query text. +- Local `Autocomplete`, `TagsInput`, `PillsInput`, and `MultiSelect` component modules do not exist yet; those names describe the target Mantine-inspired component set. + +The new hook must not create an unrelated parallel system. It should become the backing store for the combobox primitive layer while preserving existing public component behavior where practical. + +## Existing Hooks and Helpers to Reuse + +The implementation should fit the repo's existing "provider hook plus per-element hook" pattern instead of inventing a separate prop-building style. + +Relevant existing pieces: + +- `use_controlled`: existing controlled/uncontrolled state helper. `use_disclosure()` should build on this or replace repeated boolean open-state usage with a public transition-aware abstraction. +- `use_focus_provider`: creates a signal-backed `FocusState` used by roving focus systems. +- `use_focus_entry_disabled`: registers an item index and disabled state with a focus provider. +- `use_focus_control_disabled` / `use_focus_controlled_item_disabled`: return `onmounted` handlers for focusable elements and keep mounted-node focus control outside component bodies. +- `use_deferred_focus`: handles "focus first/last after something opens" behavior. +- `use_listbox_container`: wires listbox id/render state and animated open rendering. +- `use_listbox_option`: registers an option id, index, text value, disabled state, and value metadata. +- `use_selectable_root` / `use_selectable_option`: current higher-level composition of controlled open state, focus state, option registry, and selected values. +- `pointer_select_start`, `pointer_select_commit`, and `pointer_select_cancel`: normalize pointer selection and avoid accidental touch scroll selection. + +Preferred combobox direction: + +- Factor reusable focus/listbox/option-registry behavior out of `SelectableContext` where needed rather than duplicating it inside `ComboboxStore`. +- Keep `SelectableContext` for components that own selected values, but avoid making `use_combobox()` depend on selected-value ownership. +- Follow the local hook shape where root hooks provide clone/copy signal-backed context handles and element hooks return event/mounted handlers to spread into rendered elements. +- If a hook needs to return a bundle for spreading, use a small typed struct with event handlers and computed ids/attributes rather than ad hoc attributes in unrelated component code. + +## Core Boundary + +`use_combobox()` should be value-agnostic. + +It should own: + +- dropdown opened state +- open, close, and toggle behavior +- event source for open/close requests +- highlighted option index +- option registration +- disabled and invisible option skipping +- active-option selection +- selected-option submit request +- target/search focus wiring +- focus target/search methods +- list and active-descendant ids + +It should not own: + +- selected value +- selected value arrays +- tag arrays +- pill rendering +- query filtering in the first implementation +- virtualizer scroll state +- component-specific clear, blur, or create-new-item behavior + +Higher-level components should compose `use_combobox()` and then own their domain state. + +## Public API Shape + +Add a new module: + +```text +primitives/src/combobox/hook.rs +``` + +Export it from: + +```text +primitives/src/combobox/mod.rs +``` + +Initial public types: + +```rust +pub enum ComboboxDropdownEventSource { + Keyboard, + Mouse, + Unknown, +} + +pub struct UseComboboxOptions { + pub opened: Option, + pub default_opened: Option, + pub on_opened_change: Option>, + pub on_dropdown_open: Option>, + pub on_dropdown_close: Option>, + pub loop_navigation: bool, +} + +pub struct ComboboxStore { + // private fields +} + +pub struct ComboboxSubmittedOption { + pub id: String, + pub index: usize, + // Additional metadata can be added here without making the hook own selected values. +} + +pub fn use_combobox(options: UseComboboxOptions) -> ComboboxStore; +``` + +Do not expose the current crate-internal `Controlled` in this public API unless it is intentionally made public and documented. The initial hook can use explicit `opened`, `default_opened`, and callback fields, or it can delegate that open-state trio to a new public `use_disclosure()` hook if that hook is added first. + +Preferred direction: introduce `use_disclosure()` as the reusable public open/closed state primitive, then have `use_combobox()` build on it for dropdown state. `use_disclosure()` should own controlled/uncontrolled boolean state plus transition-aware `open`, `close`, and `toggle` helpers. `use_combobox()` should add combobox-specific event-source callbacks on top. + +`ComboboxStore` should be a clone/copy signal-backed handle that can be moved into Dioxus closures and contexts. It should follow the local shape of `ComboboxContext` and `SelectableContext`: cheap handles containing signals, memos, and callbacks rather than a uniquely borrowed mutable state object. + +## Store Methods + +The store should expose methods equivalent to Mantine's behavior, adapted to Rust naming and local semantics: + +```rust +impl ComboboxStore { + pub fn dropdown_opened(&self) -> bool; + pub fn open_dropdown(&self, source: ComboboxDropdownEventSource); + pub fn close_dropdown(&self, source: ComboboxDropdownEventSource); + pub fn toggle_dropdown(&self, source: ComboboxDropdownEventSource); + + pub fn highlighted_option_index(&self) -> Option; + pub fn select_option(&self, index: usize) -> Option; + pub fn select_first_option(&self) -> Option; + pub fn select_active_option(&self) -> Option; + pub fn select_next_option(&self) -> Option; + pub fn select_previous_option(&self) -> Option; + pub fn reset_selected_option(&self); + pub fn update_selected_option_index(&self, target: ComboboxIndexTarget); + + pub fn submitted_option(&self) -> Option; + + pub fn focus_target(&self); + pub fn focus_search_input(&self); +} +``` + +Use `highlighted_option_index` internally and publicly when possible. Mantine calls this `selectedOptionIndex`, but in this repo "selected" already means selected value, so "highlighted" is less ambiguous. + +The method signatures above are intentionally signal-handle style. Future implementation may adjust exact argument types, but it should not require an exclusive `&mut ComboboxStore` borrow for normal event handlers. + +Mounted-node registration for targets and search inputs should stay behind declarative element hooks such as `use_combobox_target()` and `use_combobox_search()`, whose handles expose `spread()` for the rendered element. Raw attribute helpers such as `use_combobox_target_attributes()` and `use_combobox_search_attributes()` may remain as compatibility wrappers, but `ComboboxStore` should not expose public mount-registration methods. + +Navigation methods should return stable option keys or submitted-option metadata, not selected values. Root/context code should translate the returned option metadata into the component's value behavior. + +## Option Registry + +Mantine uses DOM queries under a list id. Dioxus should prefer an explicit Rust registry. + +Each option should register: + +- stable id +- index/order +- option value or submit payload data +- disabled state +- visible state +- active state +- mounted node, if available + +`ComboboxOption` should register and unregister itself through the store/context lifecycle. + +Submit ownership should stay at the combobox root/context boundary, matching Mantine's `Combobox` `onOptionSubmit` model. Options provide value/id metadata; the store can request submission of the currently highlighted option, but the root-level context decides how to handle that submitted value. Avoid per-option submit handlers as the default model because they make `Autocomplete`, `Select`, `SelectMulti`, creatable tags, and custom option rendering harder to compose. + +The preferred dispatch shape is: + +```text +keyboard/pointer event + -> store selects or looks up highlighted option + -> store returns ComboboxSubmittedOption metadata + -> ComboboxContext/root on_option_submit handles component-specific value changes +``` + +This keeps the store value-agnostic while still giving `Autocomplete`, `Select`, `SelectMulti`, `TagsInput`, and custom combobox users a single root-level submit path. + +Navigation should walk the registry and skip disabled or invisible options. Dynamic lists and filtering should update the registry without leaving stale highlighted indices. + +Existing rendered attributes should be preserved: + +- `data-highlighted` +- `data-disabled` +- `data-selected` +- `aria-selected` +- `aria-disabled` + +Only add Mantine-style `data-combobox-*` attributes if there is a concrete styling, testing, or compatibility reason. Do not replace the existing attributes because styled wrappers may depend on them. + +## Open and Close Semantics + +Open and close callbacks should follow Mantine's transition semantics: + +- `open_dropdown` should call `on_dropdown_open` only when transitioning from closed to open. +- `close_dropdown` should call `on_dropdown_close` only when transitioning from open to closed. +- `on_opened_change` should reflect actual state changes, not repeated requests to set the current state. +- event source should be preserved through `toggle_dropdown`. + +This should be handled inside the store instead of relying directly on the current `use_controlled` helper, because the helper may call callbacks on set requests even when transition-specific callbacks should not fire. + +## Focus Model + +React refs from Mantine map to Dioxus mounted-node registration. + +The store should hold optional mounted data for: + +- target +- search input +- registered options, if option scrolling/focus is later supported + +`focus_target()` and `focus_search_input()` should no-op safely when mounted data is absent, including SSR. If deferred focus is needed to match Mantine behavior, implement it explicitly and clean up timers/tasks on drop. + +The composition model should account for more than the current `ComboboxInput` component. Mantine separates target, events target, dropdown target, search input, dropdown, options, and option components. The Dioxus implementation should support these shapes from the beginning: + +- input-as-target autocomplete +- button or custom element as target +- search input inside dropdown +- pill input where events target and dropdown target are not the same rendered node +- non-input target with keyboard event handling + +The first implementation should include these distinct primitive pieces instead of treating `ComboboxInput` as the only valid target. The store/context should support separate target, events target, dropdown target, and search input wiring from the beginning. + +## Query and Filtering Boundary + +Keep query and filtering in the existing component/context layer for the first implementation. + +Current combobox behavior includes: + +- controlled or uncontrolled query +- `default_query` +- `on_query_change` +- filter function +- empty rendering +- closed input showing selected text while open input shows query + +Moving all of that into `use_combobox()` immediately would make the hook too opinionated for `TagsInput`, `PillsInput`, and other future inputs. A later `use_autocomplete()` or component-specific wrapper can combine `use_combobox()` with query state. + +## Compatibility Wrapper + +The existing `Combobox` component should remain compatible. + +It can keep value ownership through existing selectable primitives while delegating interaction state to `ComboboxStore`: + +- option submit calls existing selected-value machinery +- single-select submit closes the dropdown +- current query/filter behavior remains in the wrapper/context +- existing styled wrappers continue to render the same visible structure + +This preserves the public component API while making the shared interaction logic reusable. + +## Virtualization + +Virtualized combobox support should be designed as an adapter, not baked into the base hook. + +Future API: + +```rust +pub fn use_virtualized_combobox(options: UseVirtualizedComboboxOptions) -> ComboboxStore; +``` + +Virtualized options should provide: + +- total option count +- disabled predicate by index +- option id by index +- active option index +- highlighted option index +- external highlighted-index setter +- scroll-to-index callback +- submit callback by index + +The base hook should not require all options to be mounted. The first implementation should keep the registry abstraction narrow enough that a virtual registry backend can be added without redesigning every store method. + +The lower-level `primitives/src/virtual/*` virtualizer algorithms are the intended reusable layer for combobox virtualization. The existing `virtual_list` component renders generic list/listitem semantics. A virtualized combobox adapter must provide listbox/option semantics instead: + +- stable option ids for `aria-activedescendant` +- `role="listbox"` and `role="option"` where appropriate +- highlighted option state even when the highlighted row is not mounted +- scroll-to-index integration +- disabled option lookup by index + +Do not treat the current `virtual_list` wrapper as automatically accessible for combobox usage without this role/id integration. + +The public virtualizer module is a low-level primitive API, not a complete accessible component. Consumers are responsible for roles, ids, keyboard behavior, focus management, and scroll container wiring. + +## Higher-Level Component Composition + +Expected composition model: + +```text +use_combobox() + owns dropdown, highlighted index, focus, registry, submit dispatch + +Autocomplete + owns String value/search and maps submit to set input label + +Select + local current component owns Option value plus typeahead behavior; a future searchable Select would add query/search state + +SelectMulti + remains the existing select primitive/component path + +MultiSelect + should be added separately; owns Vec, search, max-values, pill removal, and maps submit to toggle/add + +TagsInput + owns Vec, parser, duplicate handling, search, and maps submit/Enter to add tag + +PillsInput + owns pill layout, keyboard removal, and input composition +``` + +## Implementation Waves + +### Wave 1: Store Foundation + +- Add `primitives/src/combobox/hook.rs`. +- Add public event-source/options/store types. +- Implement controlled/uncontrolled open state. +- Implement transition-aware open/close/toggle callbacks. +- Implement highlighted index state. +- Implement normal mounted option registry. +- Add unit tests for store-only behavior. + +### Wave 2: Full Primitive Anatomy Integration + +- Refactor `ComboboxContext` to carry or wrap `ComboboxStore`. +- Refactor internal `use_combobox_root` to initialize the store. +- Add or refactor primitive pieces for `ComboboxTarget`, `ComboboxEventsTarget`, `ComboboxDropdownTarget`, and `ComboboxSearch`. +- Update `ComboboxInput` to compose the appropriate target/events/search behavior instead of being the only target model. +- Add or rename `ComboboxOptions` if needed, while keeping `ComboboxList` as a compatibility alias if the current API already exposes it. +- Update `ComboboxOption` to register with the store. +- Keep root-level option submit handling in `ComboboxContext`; options should provide values, not own submit callbacks by default. +- Preserve existing component props and rendered attributes. + +Wave 2 acceptance criteria: + +- `ComboboxTarget`, `ComboboxEventsTarget`, `ComboboxDropdownTarget`, and `ComboboxSearch` can mount independently. +- events target and dropdown target can be different DOM nodes. +- `ComboboxInput` is composition sugar over the lower-level primitives, not the only target model. +- ARIA ids and `aria-activedescendant` wire correctly through split target/search/list anatomy. +- `ComboboxList` remains compatible or aliases `ComboboxOptions` without breaking existing users. +- existing option attributes and selected/highlighted behavior are preserved. +- new element-level hooks reuse the existing focus/listbox/pointer helper patterns where applicable. + +### Wave 3: Compatibility Tests + +- Add SSR/render tests for: + - `aria-controls` + - `aria-activedescendant` + - `aria-selected` + - `aria-disabled` + - data attributes + - empty state rendering + - existing `Combobox` value behavior +- Add behavior tests for: + - disabled option skipping + - invisible option skipping + - loop and no-loop navigation + - active option selection + - dynamic registry updates + - submit selected option +- Add targeted browser tests for: + - keyboard navigation + - pointer selection + - focus and blur behavior + - `focus_target()` and `focus_search_input()` + - mounted dropdown behavior + +### Wave 4: Higher-Level Components + +After the primitive store is stable: + +- build or refactor `Autocomplete` on top of `use_combobox()` +- refactor `Select` if needed +- keep existing `SelectMulti` +- add a separate `MultiSelect` on top of `use_combobox()` +- add `PillsInput` +- add `TagsInput` + +Each component should own its own value/query model and use the store only for combobox interaction behavior. + +### Wave 5: Virtualized Combobox + +- Add a virtual registry/provider shape. +- Add `use_virtualized_combobox()`. +- Do not integrate combobox virtualization through the current public `VirtualList` component as-is. +- Reuse the public lower-level virtualizer algorithms. +- Add a combobox-specific virtualized listbox wrapper that renders listbox/option roles, stable option ids, active descendant wiring, and scroll-to-index behavior. +- Add examples for large option sets. + +## Validation + +Minimum validation before considering the primitive complete: + +- targeted Rust tests for the new hook and context +- SSR/render tests for ARIA and data attributes +- targeted browser tests for focus, keyboard, pointer, blur, and mounted-node behavior +- existing combobox preview still works +- existing select/multi-select usage still compiles +- no public API break unless intentionally documented + +## Decisions + +- Introduce a public `use_disclosure()` hook for reusable controlled/uncontrolled open state, then use it from `use_combobox()` for dropdown state. `use_combobox()` should still own combobox-specific event-source semantics. +- Support explicit option order, with registration order as a fallback for simple mounted lists. Virtualized comboboxes must use explicit absolute indices. +- Defer `scroll_into_view` until mounted option scrolling and virtualizer scroll-to-index integration are implemented. +- Keep query state out of `use_combobox()`. Add a separate `use_autocomplete()` hook later if query/filter behavior repeats across components. +- Let virtualized support expose the same public combobox behavior surface, but allow a compatible wrapper type such as `VirtualizedComboboxStore` internally if the virtual registry/provider needs extra state. + +## Open Decisions + +None currently. Revisit after implementation discovery reveals new constraints. diff --git a/primitives/src/combobox/components/mod.rs b/primitives/src/combobox/components/mod.rs index 0f5521dfd..76512678a 100644 --- a/primitives/src/combobox/components/mod.rs +++ b/primitives/src/combobox/components/mod.rs @@ -21,9 +21,12 @@ pub use option::{ ComboboxItemIndicator, ComboboxItemIndicatorProps, ComboboxOption, ComboboxOptionProps, }; pub use target::{ - use_combobox_dropdown_target_attributes, use_combobox_events_target_attributes, - use_combobox_search_attributes, use_combobox_target_attributes, ComboboxDropdownTarget, - ComboboxDropdownTargetProps, ComboboxEventsTarget, ComboboxEventsTargetProps, ComboboxTarget, - ComboboxTargetProps, + use_combobox_dropdown_target, use_combobox_dropdown_target_attributes, + use_combobox_events_target, use_combobox_events_target_attributes, use_combobox_search, + use_combobox_search_attributes, use_combobox_target, use_combobox_target_attributes, + ComboboxDropdownTarget, ComboboxDropdownTargetHandle, ComboboxDropdownTargetProps, + ComboboxEventsTarget, ComboboxEventsTargetHandle, ComboboxEventsTargetProps, + ComboboxSearchHandle, ComboboxTarget, ComboboxTargetHandle, ComboboxTargetProps, + UseComboboxSearchOptions, }; pub use virtualized::{VirtualizedComboboxOptions, VirtualizedComboboxOptionsProps}; diff --git a/primitives/src/combobox/components/target.rs b/primitives/src/combobox/components/target.rs index b695a10a3..17eef1142 100644 --- a/primitives/src/combobox/components/target.rs +++ b/primitives/src/combobox/components/target.rs @@ -64,22 +64,58 @@ fn handle_events_target_keydown(event: KeyboardEvent, mut ctx: ComboboxContext, } } +/// Declarative props for the combobox focus target element. +#[derive(Clone, Copy)] +pub struct ComboboxTargetHandle { + ctx: ComboboxContext, +} + +impl ComboboxTargetHandle { + /// Returns attributes to spread onto the interactive target element. + pub fn spread(&self) -> Vec { + let ctx = self.ctx; + + attributes!(div { + "data-combobox-target": true, + onmounted: move |event| { + ctx.store.register_target_mount_ref(event.data()); + }, + }) + } + + /// Focuses the mounted target element. + pub fn focus(&self) { + self.ctx.store.focus_target(); + } +} + +/// Returns a handle for the combobox focus target element. +pub fn use_combobox_target() -> ComboboxTargetHandle { + ComboboxTargetHandle { + ctx: use_context::(), + } +} + /// Returns attributes that register the current element as the combobox focus target. +/// +/// Prefer [`use_combobox_target`] for new code. pub fn use_combobox_target_attributes() -> Vec { - let ctx = use_context::(); + use_combobox_target().spread() +} - attributes!(div { - "data-combobox-target": true, - onmounted: move |event| { - ctx.store.register_target_mount_ref(event.data()); - }, - }) +#[derive(Clone, Copy)] +struct ComboboxTargetWrapperHandle {} + +impl ComboboxTargetWrapperHandle { + fn spread(&self) -> Vec { + attributes!(div { + "data-combobox-target": true, + }) + } } -fn use_combobox_target_wrapper_attributes() -> Vec { - attributes!(div { - "data-combobox-target": true, - }) +fn use_combobox_target_wrapper() -> ComboboxTargetWrapperHandle { + ComboboxTargetWrapperHandle {} } /// Props for [`ComboboxTarget`]. @@ -99,19 +135,19 @@ pub struct ComboboxTargetProps { /// Renders a structural wrapper around the combobox target area. /// -/// Use [`use_combobox_target_attributes`] on a custom interactive element when +/// Use [`use_combobox_target`] on a custom interactive element when /// that element should receive [`ComboboxStore::focus_target`](crate::combobox::ComboboxStore::focus_target). #[component] pub fn ComboboxTarget(props: ComboboxTargetProps) -> Element { + let target = use_combobox_target(); + let wrapper = use_combobox_target_wrapper(); + if let Some(dynamic) = props.r#as { - let merged = merge_attributes(vec![use_combobox_target_attributes(), props.attributes]); + let merged = merge_attributes(vec![target.spread(), props.attributes]); return dynamic.call(merged); } - let merged = merge_attributes(vec![ - use_combobox_target_wrapper_attributes(), - props.attributes, - ]); + let merged = merge_attributes(vec![wrapper.spread(), props.attributes]); rsx! { div { @@ -121,40 +157,77 @@ pub fn ComboboxTarget(props: ComboboxTargetProps) -> Element { } } -/// Returns attributes for the combobox events target. -pub fn use_combobox_events_target_attributes() -> Vec { +/// Declarative props for the combobox element that owns trigger events. +#[derive(Clone, Copy)] +pub struct ComboboxEventsTargetHandle { + ctx: ComboboxContext, + open: Memo, + active_descendant: Memo>, + disabled: ReadSignal, +} + +impl ComboboxEventsTargetHandle { + /// Returns attributes to spread onto the combobox events target. + pub fn spread(&self) -> Vec { + let ctx = self.ctx; + let open = self.open; + let active_descendant = self.active_descendant; + let disabled = self.disabled; + + attributes!(div { + role: "combobox", + tabindex: if disabled() { "-1" } else { "0" }, + aria_haspopup: "listbox", + aria_expanded: open(), + aria_controls: ctx.selectable.list_id(), + aria_activedescendant: active_descendant(), + aria_disabled: disabled(), + "data-combobox-events-target": true, + "data-state": if open() { "open" } else { "closed" }, + "data-disabled": disabled(), + onclick: move |event| { + if disabled() { + event.prevent_default(); + event.stop_propagation(); + return; + } + if !open() { + ctx.set_query.call(String::new()); + } + ctx.store + .toggle_dropdown(ComboboxDropdownEventSource::Mouse); + }, + onkeydown: move |event| { + handle_events_target_keydown(event, ctx, open); + }, + }) + } + + /// Returns whether the dropdown is currently open. + pub fn opened(&self) -> bool { + (self.open)() + } +} + +/// Returns a handle for the combobox element that owns trigger events. +pub fn use_combobox_events_target() -> ComboboxEventsTargetHandle { let ctx = use_context::(); let open = use_memo(move || ctx.store.dropdown_opened()); let active_descendant = active_descendant(ctx, open); - let disabled = ctx.selectable.disabled; - - attributes!(div { - role: "combobox", - tabindex: if disabled() { "-1" } else { "0" }, - aria_haspopup: "listbox", - aria_expanded: open(), - aria_controls: ctx.selectable.list_id(), - aria_activedescendant: active_descendant(), - aria_disabled: disabled(), - "data-combobox-events-target": true, - "data-state": if open() { "open" } else { "closed" }, - "data-disabled": disabled(), - onclick: move |event| { - if disabled() { - event.prevent_default(); - event.stop_propagation(); - return; - } - if !open() { - ctx.set_query.call(String::new()); - } - ctx.store - .toggle_dropdown(ComboboxDropdownEventSource::Mouse); - }, - onkeydown: move |event| { - handle_events_target_keydown(event, ctx, open); - }, - }) + + ComboboxEventsTargetHandle { + ctx, + open, + active_descendant, + disabled: ctx.selectable.disabled, + } +} + +/// Returns attributes for the combobox events target. +/// +/// Prefer [`use_combobox_events_target`] for new code. +pub fn use_combobox_events_target_attributes() -> Vec { + use_combobox_events_target().spread() } /// Props for [`ComboboxEventsTarget`]. @@ -175,10 +248,8 @@ pub struct ComboboxEventsTargetProps { /// Element that owns combobox trigger ARIA and keyboard/pointer interactions. #[component] pub fn ComboboxEventsTarget(props: ComboboxEventsTargetProps) -> Element { - let merged = merge_attributes(vec![ - use_combobox_events_target_attributes(), - props.attributes, - ]); + let target = use_combobox_events_target(); + let merged = merge_attributes(vec![target.spread(), props.attributes]); if let Some(dynamic) = props.r#as { return dynamic.call(merged); @@ -192,15 +263,42 @@ pub fn ComboboxEventsTarget(props: ComboboxEventsTargetProps) -> Element { } } -/// Returns attributes for an element that marks the dropdown anchoring target. -pub fn use_combobox_dropdown_target_attributes() -> Vec { +/// Declarative props for the dropdown anchoring target. +#[derive(Clone, Copy)] +pub struct ComboboxDropdownTargetHandle { + open: Memo, +} + +impl ComboboxDropdownTargetHandle { + /// Returns attributes to spread onto the dropdown anchoring target. + pub fn spread(&self) -> Vec { + let open = self.open; + + attributes!(div { + "data-combobox-dropdown-target": true, + "data-state": if open() { "open" } else { "closed" }, + }) + } + + /// Returns whether the dropdown is currently open. + pub fn opened(&self) -> bool { + (self.open)() + } +} + +/// Returns a handle for the dropdown anchoring target. +pub fn use_combobox_dropdown_target() -> ComboboxDropdownTargetHandle { let ctx = use_context::(); let open = use_memo(move || ctx.store.dropdown_opened()); - attributes!(div { - "data-combobox-dropdown-target": true, - "data-state": if open() { "open" } else { "closed" }, - }) + ComboboxDropdownTargetHandle { open } +} + +/// Returns attributes for an element that marks the dropdown anchoring target. +/// +/// Prefer [`use_combobox_dropdown_target`] for new code. +pub fn use_combobox_dropdown_target_attributes() -> Vec { + use_combobox_dropdown_target().spread() } /// Props for [`ComboboxDropdownTarget`]. @@ -221,10 +319,8 @@ pub struct ComboboxDropdownTargetProps { /// Wraps dropdown content when the dropdown target differs from the events target. #[component] pub fn ComboboxDropdownTarget(props: ComboboxDropdownTargetProps) -> Element { - let merged = merge_attributes(vec![ - use_combobox_dropdown_target_attributes(), - props.attributes, - ]); + let target = use_combobox_dropdown_target(); + let merged = merge_attributes(vec![target.spread(), props.attributes]); if let Some(dynamic) = props.r#as { return dynamic.call(merged); @@ -238,107 +334,198 @@ pub fn ComboboxDropdownTarget(props: ComboboxDropdownTargetProps) -> Element { } } -/// Returns attributes for a native combobox search input. -pub fn use_combobox_search_attributes( +/// Options for [`use_combobox_search`]. +#[derive(Clone, Copy)] +pub struct UseComboboxSearchOptions { + /// Placeholder shown when the input is empty. + pub placeholder: ReadSignal, + /// Optional id for the input element. + pub id: ReadSignal>, + /// Whether this input should also be the focus target. + pub register_target: bool, + /// Whether to show selected option text while the dropdown is closed. + pub show_selected_text: bool, +} + +impl Default for UseComboboxSearchOptions { + fn default() -> Self { + Self { + placeholder: ReadSignal::new(Signal::new(String::new())), + id: ReadSignal::new(Signal::new(None)), + register_target: false, + show_selected_text: true, + } + } +} + +/// Declarative props and controls for a native combobox search input. +#[derive(Clone, Copy)] +pub struct ComboboxSearchHandle { + ctx: ComboboxContext, placeholder: ReadSignal, - id: ReadSignal>, + id: ReadSignal, register_target: bool, - show_selected_text: bool, -) -> Vec { - let mut ctx = use_context::(); + open: Memo, + display_value: Memo, + active_descendant: Memo>, + disabled: ReadSignal, +} +impl ComboboxSearchHandle { + /// Returns attributes to spread onto the search input. + pub fn spread(&self) -> Vec { + let mut ctx = self.ctx; + let placeholder = self.placeholder; + let id = self.id; + let register_target = self.register_target; + let open = self.open; + let set_query = ctx.set_query; + let display_value = self.display_value; + let active_descendant = self.active_descendant; + let disabled = self.disabled; + + attributes!(input { + id, + r#type: "text", + value: display_value(), + placeholder, + autocomplete: "off", + spellcheck: "false", + disabled: disabled(), + + role: "combobox", + aria_autocomplete: "list", + aria_haspopup: "listbox", + aria_expanded: open(), + aria_controls: ctx.selectable.list_id(), + aria_activedescendant: active_descendant(), + + "data-combobox-search": true, + "data-state": if open() { "open" } else { "closed" }, + + onclick: move |event| { + if disabled() { + event.prevent_default(); + event.stop_propagation(); + return; + } + if !open() { + set_query.call(String::new()); + ctx.set_open(true); + } + }, + oninput: move |event| { + if disabled() { + event.prevent_default(); + event.stop_propagation(); + return; + } + let was_open = open(); + let value = event.value(); + let next_query = if was_open { + value + } else { + ctx.selectable + .selected_text() + .and_then(|selected| { + value + .strip_prefix(&selected) + .map(ToString::to_string) + }) + .unwrap_or(value) + }; + set_query.call(next_query); + if was_open { + ctx.selectable.focus_state.set_focus(None); + ctx.store.reset_selected_option(); + } else { + ctx.set_open(true); + } + }, + onkeydown: move |event| { + handle_events_target_keydown(event, ctx, open); + }, + onmounted: move |event| { + if register_target { + ctx.store.register_target_mount_ref(event.data()); + } + ctx.store.register_search_mount_ref(event.data()); + }, + onblur: move |_| { + if open() { + ctx.set_open(false); + } + }, + }) + } + + /// Returns the current search query. + pub fn query(&self) -> String { + self.ctx.query.cloned() + } + + /// Updates the search query. + pub fn search_for(&self, query: impl Into) { + self.ctx.set_query.call(query.into()); + } + + /// Focuses the mounted search input. + pub fn focus(&self) { + self.ctx.store.focus_search_input(); + } + + /// Returns whether the dropdown is currently open. + pub fn opened(&self) -> bool { + (self.open)() + } +} + +/// Returns a handle for a native combobox search input. +pub fn use_combobox_search(options: UseComboboxSearchOptions) -> ComboboxSearchHandle { + let ctx = use_context::(); let fallback_id = use_unique_id(); - let id = crate::use_id_or(fallback_id, id); + let id = crate::use_id_or(fallback_id, options.id); let open = use_memo(move || ctx.store.dropdown_opened()); - let query = ctx.query; - let set_query = ctx.set_query; let active_descendant = active_descendant(ctx, open); - let display_value = use_memo(move || { if open() { - query.cloned() - } else if show_selected_text { + ctx.query.cloned() + } else if options.show_selected_text { ctx.selectable.selected_text().unwrap_or_default() } else { String::new() } }); - let disabled = ctx.selectable.disabled; - attributes!(input { - id, - r#type: "text", - value: display_value(), + ComboboxSearchHandle { + ctx, + placeholder: options.placeholder, + id: id.into(), + register_target: options.register_target, + open, + display_value, + active_descendant, + disabled: ctx.selectable.disabled, + } +} + +/// Returns attributes for a native combobox search input. +/// +/// Prefer [`use_combobox_search`] for new code. +pub fn use_combobox_search_attributes( + placeholder: ReadSignal, + id: ReadSignal>, + register_target: bool, + show_selected_text: bool, +) -> Vec { + use_combobox_search(UseComboboxSearchOptions { placeholder, - autocomplete: "off", - spellcheck: "false", - disabled: disabled(), - - role: "combobox", - aria_autocomplete: "list", - aria_haspopup: "listbox", - aria_expanded: open(), - aria_controls: ctx.selectable.list_id(), - aria_activedescendant: active_descendant(), - - "data-combobox-search": true, - "data-state": if open() { "open" } else { "closed" }, - - onclick: move |event| { - if disabled() { - event.prevent_default(); - event.stop_propagation(); - return; - } - if !open() { - set_query.call(String::new()); - ctx.set_open(true); - } - }, - oninput: move |event| { - if disabled() { - event.prevent_default(); - event.stop_propagation(); - return; - } - let was_open = open(); - let value = event.value(); - let next_query = if was_open { - value - } else { - ctx.selectable - .selected_text() - .and_then(|selected| { - value - .strip_prefix(&selected) - .map(ToString::to_string) - }) - .unwrap_or(value) - }; - set_query.call(next_query); - if was_open { - ctx.selectable.focus_state.set_focus(None); - ctx.store.reset_selected_option(); - } else { - ctx.set_open(true); - } - }, - onkeydown: move |event| { - handle_events_target_keydown(event, ctx, open); - }, - onmounted: move |event| { - if register_target { - ctx.store.register_target_mount_ref(event.data()); - } - ctx.store.register_search_mount_ref(event.data()); - }, - onblur: move |_| { - if open() { - ctx.set_open(false); - } - }, + id, + register_target, + show_selected_text, }) + .spread() } pub(super) fn render_combobox_search( @@ -348,10 +535,13 @@ pub(super) fn render_combobox_search( register_target: bool, show_selected_text: bool, ) -> Element { - let merged = merge_attributes(vec![ - use_combobox_search_attributes(placeholder, id, register_target, show_selected_text), - attributes, - ]); + let search = use_combobox_search(UseComboboxSearchOptions { + placeholder, + id, + register_target, + show_selected_text, + }); + let merged = merge_attributes(vec![search.spread(), attributes]); rsx! { input { diff --git a/primitives/src/combobox/hook.rs b/primitives/src/combobox/hook.rs index c2877e4b3..5b90aaafb 100644 --- a/primitives/src/combobox/hook.rs +++ b/primitives/src/combobox/hook.rs @@ -278,23 +278,16 @@ impl ComboboxStore { Some(submitted) } - /// Registers the mounted target element used by [`focus_target`](Self::focus_target). - pub fn register_target_mount(&self, mounted: MountedData) { - self.register_target_mount_ref(Rc::new(mounted)); - } - - /// Registers the mounted search input used by - /// [`focus_search_input`](Self::focus_search_input). - pub fn register_search_mount(&self, mounted: MountedData) { - self.register_search_mount_ref(Rc::new(mounted)); - } - - /// Focuses the registered target element when mounted. + /// Focuses the element registered through + /// [`use_combobox_target`](crate::combobox::use_combobox_target) + /// when mounted. pub fn focus_target(&self) { focus_mounted(self.target_mount); } - /// Focuses the registered search input when mounted. + /// Focuses the input registered through + /// [`use_combobox_search`](crate::combobox::use_combobox_search) + /// when mounted. pub fn focus_search_input(&self) { focus_mounted(self.search_mount); } diff --git a/primitives/src/combobox/mod.rs b/primitives/src/combobox/mod.rs index 6acccc992..d4ee79bee 100644 --- a/primitives/src/combobox/mod.rs +++ b/primitives/src/combobox/mod.rs @@ -10,16 +10,18 @@ mod context; mod hook; pub use components::{ - use_combobox_dropdown_target_attributes, use_combobox_events_target_attributes, - use_combobox_search_attributes, use_combobox_target_attributes, Autocomplete, - AutocompleteProps, Combobox, ComboboxDropdownTarget, ComboboxDropdownTargetProps, - ComboboxEmpty, ComboboxEmptyProps, ComboboxEventsTarget, ComboboxEventsTargetProps, - ComboboxInput, ComboboxInputProps, ComboboxItemIndicator, ComboboxItemIndicatorProps, - ComboboxList, ComboboxListProps, ComboboxOption, ComboboxOptionProps, ComboboxOptions, - ComboboxOptionsProps, ComboboxProps, ComboboxSearch, ComboboxSearchProps, ComboboxTarget, - ComboboxTargetProps, MultiSelect, MultiSelectProps, Pill, PillProps, PillsInput, - PillsInputProps, TagsInput, TagsInputProps, VirtualizedComboboxOptions, - VirtualizedComboboxOptionsProps, + use_combobox_dropdown_target, use_combobox_dropdown_target_attributes, + use_combobox_events_target, use_combobox_events_target_attributes, use_combobox_search, + use_combobox_search_attributes, use_combobox_target, use_combobox_target_attributes, + Autocomplete, AutocompleteProps, Combobox, ComboboxDropdownTarget, + ComboboxDropdownTargetHandle, ComboboxDropdownTargetProps, ComboboxEmpty, ComboboxEmptyProps, + ComboboxEventsTarget, ComboboxEventsTargetHandle, ComboboxEventsTargetProps, ComboboxInput, + ComboboxInputProps, ComboboxItemIndicator, ComboboxItemIndicatorProps, ComboboxList, + ComboboxListProps, ComboboxOption, ComboboxOptionProps, ComboboxOptions, ComboboxOptionsProps, + ComboboxProps, ComboboxSearch, ComboboxSearchHandle, ComboboxSearchProps, ComboboxTarget, + ComboboxTargetHandle, ComboboxTargetProps, MultiSelect, MultiSelectProps, Pill, PillProps, + PillsInput, PillsInputProps, TagsInput, TagsInputProps, UseComboboxSearchOptions, + VirtualizedComboboxOptions, VirtualizedComboboxOptionsProps, }; pub use context::default_combobox_filter; From 2215c3dad41ede2387cda896028c89b91f9a1fa2 Mon Sep 17 00:00:00 2001 From: Saren Date: Wed, 27 May 2026 10:25:51 -0700 Subject: [PATCH 5/8] fix(combobox): stabilize virtualized options --- playwright/combobox.spec.ts | 4 ++-- .../src/combobox/components/virtualized.rs | 21 ++++++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/playwright/combobox.spec.ts b/playwright/combobox.spec.ts index e912d76d4..33250e402 100644 --- a/playwright/combobox.spec.ts +++ b/playwright/combobox.spec.ts @@ -274,8 +274,8 @@ test("virtualized variant shows visible options when opened", async ({ page }) = const menu = list(page); await expect(menu).toBeVisible(); - await expect(menu.getByRole("option", { name: "Option 0" })).toBeVisible(); - await expect(menu.getByRole("option", { name: "Option 1" })).toBeVisible(); + await expect(menu.getByRole("option", { name: "Option 0", exact: true })).toBeVisible(); + await expect(menu.getByRole("option", { name: "Option 1", exact: true })).toBeVisible(); }); test("touch selection commits and closes", async ({ browser, browserName }) => { diff --git a/primitives/src/combobox/components/virtualized.rs b/primitives/src/combobox/components/virtualized.rs index d710445aa..36b5dcafb 100644 --- a/primitives/src/combobox/components/virtualized.rs +++ b/primitives/src/combobox/components/virtualized.rs @@ -217,9 +217,28 @@ pub fn VirtualizedComboboxOptions(props: VirtualizedComboboxOptionsProps) -> Ele let measurements_read = measurements.read(); let virtual_items = get_virtual_items(&state, &measurements_read, (props.buffer)()); + let viewport_size = *state.viewport_size().read(); + let virtual_items = if virtual_items.is_empty() && viewport_size == 0 { + let estimated_size = measurements_read + .first() + .map(|item| item.size()) + .filter(|size| *size > 0) + .unwrap_or(36); + let estimated_rows = (240 / estimated_size).max(1) as usize; + let bootstrap_count = measurements_read + .len() + .min(estimated_rows.saturating_add((props.buffer)())); + measurements_read + .iter() + .take(bootstrap_count) + .cloned() + .collect::>() + } else { + virtual_items + }; let total_height = get_total_size(&state, &measurements_read); let top_offset = virtual_items.first().map(|item| item.start()).unwrap_or(0); - let canvas_height = total_height.max(*state.viewport_size().peek()); + let canvas_height = total_height.max(viewport_size); let set_size = visible_indices.read().len().to_string(); rsx! { From d6e2b7f9a0b3117351937992b1c7bd171585bba3 Mon Sep 17 00:00:00 2001 From: Saren Date: Wed, 27 May 2026 10:26:07 -0700 Subject: [PATCH 6/8] fix(primitives): satisfy clippy warnings --- preview/src/main.rs | 13 +++-- .../src/combobox/components/combobox.rs | 52 ++++++++++++------- .../src/combobox/components/high_level.rs | 28 +++++----- primitives/src/combobox/context.rs | 10 ---- primitives/src/selectable.rs | 36 +++++++++---- 5 files changed, 83 insertions(+), 56 deletions(-) diff --git a/preview/src/main.rs b/preview/src/main.rs index c5fb6f408..a5bd5e6f0 100644 --- a/preview/src/main.rs +++ b/preview/src/main.rs @@ -1267,10 +1267,17 @@ fn WidgetMasonry() -> Element { } #[allow(unpredictable_function_pointer_comparisons)] +#[derive(Props, Clone, PartialEq)] +struct MasonryCardProps { + component: fn() -> Element, + #[props(default)] + popout: bool, +} + #[component] -fn MasonryCard(component: fn() -> Element, #[props(default)] popout: bool) -> Element { - let Comp = component; - let class = if popout { +fn MasonryCard(props: MasonryCardProps) -> Element { + let Comp = props.component; + let class = if props.popout { "dx-widget-card dx-widget-card-popout" } else { "dx-widget-card" diff --git a/primitives/src/combobox/components/combobox.rs b/primitives/src/combobox/components/combobox.rs index 4ddac268d..7565de928 100644 --- a/primitives/src/combobox/components/combobox.rs +++ b/primitives/src/combobox/components/combobox.rs @@ -75,13 +75,16 @@ pub struct ComboboxProps { pub(super) fn use_combobox_root( values: Memo>, set_value: Callback, - selection_mode: SelectionMode, - disabled: ReadSignal, - roving_loop: ReadSignal, - open: Controlled, - query: Controlled, - filter: Callback<(String, String), bool>, + config: ComboboxRootConfig, ) -> Memo { + let ComboboxRootConfig { + selection_mode, + disabled, + roving_loop, + open, + query, + filter, + } = config; let store = use_combobox(UseComboboxOptions { opened: open.value, default_opened: open.default, @@ -111,6 +114,15 @@ pub(super) fn use_combobox_root( use_memo(move || store.dropdown_opened()) } +pub(super) struct ComboboxRootConfig { + pub(super) selection_mode: SelectionMode, + pub(super) disabled: ReadSignal, + pub(super) roving_loop: ReadSignal, + pub(super) open: Controlled, + pub(super) query: Controlled, + pub(super) filter: Callback<(String, String), bool>, +} + /// A single-select autocomplete input with a filterable popup list. #[component] pub fn Combobox(props: ComboboxProps) -> Element { @@ -124,20 +136,22 @@ pub fn Combobox(props: ComboboxProps) -> Elem let open = use_combobox_root( selected, set_value, - SelectionMode::Single, - props.disabled, - props.roving_loop, - Controlled { - value: props.open, - default: props.default_open, - on_change: props.on_open_change, - }, - Controlled { - value: props.query, - default: props.default_query, - on_change: props.on_query_change, + ComboboxRootConfig { + selection_mode: SelectionMode::Single, + disabled: props.disabled, + roving_loop: props.roving_loop, + open: Controlled { + value: props.open, + default: props.default_open, + on_change: props.on_open_change, + }, + query: Controlled { + value: props.query, + default: props.default_query, + on_change: props.on_query_change, + }, + filter: props.filter, }, - props.filter, ); rsx! { diff --git a/primitives/src/combobox/components/high_level.rs b/primitives/src/combobox/components/high_level.rs index 34d2843e5..0d16f41a4 100644 --- a/primitives/src/combobox/components/high_level.rs +++ b/primitives/src/combobox/components/high_level.rs @@ -211,20 +211,22 @@ pub fn MultiSelect(props: MultiSelectProps) - let open = use_combobox_root( values, set_value, - SelectionMode::Multiple, - props.disabled, - props.roving_loop, - Controlled { - value: props.open, - default: props.default_open, - on_change: props.on_open_change, - }, - Controlled { - value: props.query, - default: props.default_query, - on_change: props.on_query_change, + super::combobox::ComboboxRootConfig { + selection_mode: SelectionMode::Multiple, + disabled: props.disabled, + roving_loop: props.roving_loop, + open: Controlled { + value: props.open, + default: props.default_open, + on_change: props.on_open_change, + }, + query: Controlled { + value: props.query, + default: props.default_query, + on_change: props.on_query_change, + }, + filter: props.filter, }, - props.filter, ); rsx! { diff --git a/primitives/src/combobox/context.rs b/primitives/src/combobox/context.rs index 8452b5140..8f33abf2c 100644 --- a/primitives/src/combobox/context.rs +++ b/primitives/src/combobox/context.rs @@ -44,16 +44,6 @@ impl ComboboxContext { self.predicate_for(self.query.cloned()) } - pub fn is_visible(&self, tab_index: usize) -> bool { - let predicate = self.predicate(); - self.selectable - .options - .read() - .iter() - .find(|option| option.tab_index == tab_index) - .is_some_and(predicate) - } - pub fn is_visible_text(&self, tab_index: usize, text: String) -> bool { let predicate = self.predicate(); self.selectable diff --git a/primitives/src/selectable.rs b/primitives/src/selectable.rs index 90758b497..f73fb7f4f 100644 --- a/primitives/src/selectable.rs +++ b/primitives/src/selectable.rs @@ -217,14 +217,16 @@ pub(crate) fn use_selectable_root( let initial_focus = use_signal(|| None); build_selectable_context( - open, - set_open, + SelectableRootState { + open, + set_open, + focus_state, + initial_focus, + }, values, set_value, selection_mode, disabled, - focus_state, - initial_focus, ) } @@ -241,29 +243,41 @@ pub(crate) fn use_selectable_root_with_state( let initial_focus = use_signal(|| None); build_selectable_context( - open, - set_open, + SelectableRootState { + open, + set_open, + focus_state, + initial_focus, + }, values, set_value, selection_mode, disabled, - focus_state, - initial_focus, ) } -fn build_selectable_context( +struct SelectableRootState { open: Memo, set_open: Callback, + focus_state: FocusState, + initial_focus: Signal>, +} + +fn build_selectable_context( + state: SelectableRootState, values: Memo>, set_value: Callback, selection_mode: SelectionMode, disabled: ReadSignal, - focus_state: FocusState, - initial_focus: Signal>, ) -> SelectableContext { let options: Signal> = use_signal(Vec::default); let list_id = use_signal(|| None); + let SelectableRootState { + open, + set_open, + focus_state, + initial_focus, + } = state; SelectableContext { open, From 818780dcc18c6e712bc4f0eebad5db1c485ec6ba Mon Sep 17 00:00:00 2001 From: Saren Date: Wed, 27 May 2026 10:26:13 -0700 Subject: [PATCH 7/8] fix(preview): satisfy combobox stylelint --- preview/src/components/combobox/component.rs | 7 ++++++- preview/src/components/combobox/style.css | 21 ++++++++++---------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/preview/src/components/combobox/component.rs b/preview/src/components/combobox/component.rs index 5f947721b..ed4e5c324 100644 --- a/preview/src/components/combobox/component.rs +++ b/preview/src/components/combobox/component.rs @@ -305,10 +305,15 @@ pub fn PillsInput(props: PillsInputProps) -> Element { #[component] pub fn Pill(props: PillProps) -> Element { + let base = attributes!(div { + class: Styles::dx_combobox_pill + }); + let merged = merge_attributes(vec![base, props.attributes]); + rsx! { combobox::Pill { on_remove: props.on_remove, - attributes: props.attributes, + attributes: merged, {props.children} } } diff --git a/preview/src/components/combobox/style.css b/preview/src/components/combobox/style.css index 3608ed55b..f56266e2e 100644 --- a/preview/src/components/combobox/style.css +++ b/preview/src/components/combobox/style.css @@ -12,11 +12,11 @@ .dx-combobox[data-pills-input], .dx-combobox [data-pills-input] { display: flex; - flex-wrap: wrap; - align-items: center; width: 200px; min-height: 2.25rem; box-sizing: border-box; + flex-wrap: wrap; + align-items: center; padding: 0.25rem; border-radius: 0.5rem; background: var(--light, var(--primary-color)) var(--dark, var(--primary-color-3)); @@ -53,15 +53,14 @@ .dx-combobox[data-pills-input] [data-pills-input-field], .dx-combobox [data-pills-input] [data-combobox-search], .dx-combobox [data-pills-input] [data-pills-input-field] { - appearance: none; - -webkit-appearance: none; - flex: 1 1 6rem; width: auto; min-width: 5rem; height: 1.5rem; + flex: 1 1 6rem; padding: 0 0.25rem; border: 0; border-radius: 0; + appearance: none; background: transparent; box-shadow: none; color: var(--secondary-color-1); @@ -204,13 +203,13 @@ color: var(--secondary-color-5); } -.dx-combobox [data-pill], -[data-pills-input] [data-pill] { +.dx-combobox-pill, +.dx-combobox [data-pill] { display: inline-flex; - align-items: center; max-width: 100%; height: 1.5rem; box-sizing: border-box; + align-items: center; padding: 0 0.25rem 0 0.5rem; border-radius: 0.375rem; background: var(--light, var(--primary-color-4)) var(--dark, var(--primary-color-6)); @@ -220,12 +219,11 @@ line-height: 1rem; } -.dx-combobox [data-pill] button, -[data-pills-input] [data-pill] button { +.dx-combobox-pill button, +.dx-combobox [data-pill] button { display: inline-grid; width: 1rem; height: 1rem; - place-items: center; padding: 0; border: none; border-radius: 0.25rem; @@ -233,6 +231,7 @@ color: inherit; cursor: pointer; font: inherit; + place-items: center; } .dx-combobox-demo-stack { From 25cfd4e32e754aafdbf227159ac97f09f08c2f71 Mon Sep 17 00:00:00 2001 From: Saren Date: Fri, 29 May 2026 23:18:26 -0700 Subject: [PATCH 8/8] fix VirtualizedCombobox scrollbar sizing issue. --- playwright/combobox.spec.ts | 100 ++++++ .../src/combobox/components/virtualized.rs | 326 ++++++++---------- 2 files changed, 237 insertions(+), 189 deletions(-) diff --git a/playwright/combobox.spec.ts b/playwright/combobox.spec.ts index 33250e402..530767315 100644 --- a/playwright/combobox.spec.ts +++ b/playwright/combobox.spec.ts @@ -278,6 +278,106 @@ test("virtualized variant shows visible options when opened", async ({ page }) = await expect(menu.getByRole("option", { name: "Option 1", exact: true })).toBeVisible(); }); +test("virtualized variant keeps scrollHeight stable while scrolling", async ({ page }) => { + await page.goto(blockVariantUrl("virtualized"), { timeout: 20 * 60 * 1000 }); + await page.waitForLoadState('networkidle'); + + const trigger = page.getByRole("combobox", { name: "Virtualized option picker" }); + await trigger.click(); + + const menu = list(page); + await expect(menu).toBeVisible(); + await page.waitForTimeout(500); + + const initialState = await menu.evaluate((el) => ({ + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + ratio: el.scrollHeight / el.clientHeight, + })); + + const maxScroll = initialState.scrollHeight - initialState.clientHeight; + const steps = 20; + const stepSize = maxScroll / steps; + const measurements: Array<{ + scrollTop: number; + scrollHeight: number; + clientHeight: number; + ratio: number; + }> = []; + + for (let i = 1; i <= steps; i++) { + const targetScroll = Math.round(stepSize * i); + + await menu.evaluate((el, scroll) => { + el.scrollTop = scroll; + }, targetScroll); + await page.waitForTimeout(100); + + measurements.push(await menu.evaluate((el) => ({ + scrollTop: el.scrollTop, + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + ratio: el.scrollHeight / el.clientHeight, + }))); + } + + const duringScrollMeasurements = measurements.slice(0, -1); + const scrollHeights = duringScrollMeasurements.map((m) => m.scrollHeight); + const clientHeights = duringScrollMeasurements.map((m) => m.clientHeight); + const ratios = duringScrollMeasurements.map((m) => m.ratio); + const minHeight = Math.min(...scrollHeights); + const maxHeight = Math.max(...scrollHeights); + const heightVariance = maxHeight - minHeight; + const minClientHeight = Math.min(...clientHeights); + const maxClientHeight = Math.max(...clientHeights); + const clientHeightVariance = maxClientHeight - minClientHeight; + const minRatio = Math.min(...ratios); + const maxRatio = Math.max(...ratios); + const ratioVariance = maxRatio - minRatio; + + expect( + heightVariance, + `combobox scrollHeight changed by ${heightVariance}px during scroll` + ).toBeLessThan(100); + expect( + clientHeightVariance, + `combobox clientHeight changed by ${clientHeightVariance}px during scroll` + ).toBeLessThanOrEqual(1); + expect( + ratioVariance, + `combobox scrollHeight/clientHeight ratio changed by ${ratioVariance} during scroll` + ).toBeLessThan(0.5); + + const lastMeasurement = measurements.at(-1); + expect(lastMeasurement).toBeDefined(); + + await page.waitForTimeout(650); + + const settledState = await menu.evaluate((el) => ({ + scrollTop: el.scrollTop, + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + ratio: el.scrollHeight / el.clientHeight, + })); + + expect( + Math.abs(settledState.scrollHeight - lastMeasurement!.scrollHeight), + "combobox scrollHeight shifted after the 600ms scroll debounce settled" + ).toBeLessThan(100); + expect( + Math.abs(settledState.clientHeight - lastMeasurement!.clientHeight), + "combobox clientHeight changed after the 600ms scroll debounce settled" + ).toBeLessThanOrEqual(1); + expect( + Math.abs(settledState.ratio - lastMeasurement!.ratio), + "combobox scrollHeight/clientHeight ratio shifted after the 600ms scroll debounce settled" + ).toBeLessThan(0.5); + expect( + Math.abs(settledState.scrollTop - lastMeasurement!.scrollTop), + "combobox scrollTop drifted after the 600ms scroll debounce settled" + ).toBeLessThanOrEqual(1); +}); + test("touch selection commits and closes", async ({ browser, browserName }) => { test.skip(browserName === "firefox", "Firefox does not support mobile contexts"); diff --git a/primitives/src/combobox/components/virtualized.rs b/primitives/src/combobox/components/virtualized.rs index 36b5dcafb..159fe5be7 100644 --- a/primitives/src/combobox/components/virtualized.rs +++ b/primitives/src/combobox/components/virtualized.rs @@ -1,18 +1,9 @@ //! Virtualized combobox listbox component. use dioxus::prelude::*; -use serde::Deserialize; -use std::collections::hash_map::DefaultHasher; -use std::hash::{Hash, Hasher}; use super::super::context::ComboboxContext; -use crate::{ - listbox::{use_listbox_container_with_open, use_listbox_id}, - r#virtual::{ - compute_measurements, get_total_size, get_virtual_items, resize_item, set_scroll_offset, - set_viewport_size, VirtualizerState, VirtualizerStateStoreExt, - }, -}; +use crate::listbox::{use_listbox_container_with_open, use_listbox_id}; /// Props for [`VirtualizedComboboxOptions`]. #[derive(Props, Clone, PartialEq)] @@ -55,109 +46,66 @@ pub fn VirtualizedComboboxOptions(props: VirtualizedComboboxOptionsProps) -> Ele let listbox = use_listbox_container_with_open(id, ctx.selectable, open); let render = listbox.render; - let state: Store = use_store(VirtualizerState::new); - let visible_indices = use_memo(move || { + let mut scroll_offset = use_signal(|| 0u32); + let mut viewport_size = use_signal(|| 0u32); + + // The total number of visible rows (changes when the filter changes). + let visible_count = use_memo(move || { props .visible_indices .as_ref() - .map(|indices| indices.read().clone()) - .unwrap_or_else(|| (0..(props.count)()).collect()) - }); - let visible_signature = use_memo(move || { - let mut hasher = DefaultHasher::new(); - for index in visible_indices.read().iter() { - index.hash(&mut hasher); - } - hasher.finish() + .map(|indices| indices.read().len()) + .unwrap_or_else(|| (props.count)()) }); - use_effect(move || { - let _ = visible_signature(); - state.item_size_cache().write().clear(); - state.scroll_adjustments().set(0); - state.deferred_adjustments().set(0); - state.stable_total_size().set(None); - state.stable_measurement_count().set(None); - }); - - let measurements = use_memo(move || { - let item_size_cache = state.item_size_cache(); - let item_size_cache = item_size_cache.read(); - let visible_indices = visible_indices.read(); - let visible_count = visible_indices.len(); - let estimate_cb = props + // The single row-height estimate. We call estimate_size(0) as a representative sample. + // For comboboxes all options are the same height, so this is exact. + let est = use_memo(move || { + props .estimate_size .as_ref() - .map(|callback| move |index| callback(visible_indices[index])); - compute_measurements( - visible_count, - &item_size_cache, - estimate_cb - .as_ref() - .map(|estimate| estimate as &dyn Fn(usize) -> u32), - ) + .map(|cb| { + let idx = props + .visible_indices + .as_ref() + .and_then(|s| s.read().first().copied()) + .unwrap_or(0); + cb(idx).max(1) + }) + .unwrap_or(36) }); + // Reset scroll position whenever the filter changes. use_effect(move || { - if !render() { - return; - } - - let script = r#" - const container = document.getElementById(await dioxus.recv()); - if (!container) return; - - let scrollEndTimer = null; - function publish(isScrolling) { - dioxus.send({ - offset: Math.round(container.scrollTop), - viewport: Math.min(container.clientHeight, window.innerHeight) || 240, - isScrolling - }); - } - - function onScroll() { - if (scrollEndTimer !== null) clearTimeout(scrollEndTimer); - publish(true); - scrollEndTimer = setTimeout(() => { - scrollEndTimer = null; - publish(false); - }, 300); - } - - publish(false); - container.addEventListener("scroll", onScroll, { passive: true }); - window.addEventListener("resize", () => publish(false), { passive: true }); + let _ = visible_count.read(); + scroll_offset.set(0); + spawn(async move { + sync_scroll(listbox.id.peek().clone(), 0).await; + }); + }); - await dioxus.recv(); - if (scrollEndTimer !== null) clearTimeout(scrollEndTimer); - container.removeEventListener("scroll", onScroll); - "#; - let mut eval = document::eval(script); - let _ = eval.send(listbox.id.peek().clone()); + // Read scroll position directly from the native scroll event — no JS eval loop needed. + // ScrollData carries scrollTop and clientHeight from the browser event itself. + let on_scroll = move |evt: Event| { + let data = evt.data(); + scroll_offset.set(data.scroll_top().round() as u32); + viewport_size.set(data.client_height() as u32); + }; + // On mount, capture the initial viewport height so the window calculation is correct + // before the first scroll event fires. + let on_mounted = move |e: Event| { + let data = e.data(); spawn(async move { - while let Ok(scroll_msg) = eval.recv::().await { - let correction = { - let measurements = measurements.peek(); - set_scroll_offset( - &state, - &measurements, - scroll_msg.offset, - scroll_msg.is_scrolling, - ) - }; - set_viewport_size(&state, scroll_msg.viewport); - - if let Some(delta) = correction { - let next = (scroll_msg.offset as i32 + delta).max(0) as u32; - sync_scroll(listbox.id.peek().clone(), next).await; - state.scroll_offset().set(next); - } + if let Ok(rect) = data.get_client_rect().await { + viewport_size.set(rect.size.height.round() as u32); } }); - }); + // Ensure the signal state is clean for each fresh open. + scroll_offset.set(0); + }; + // Scroll-to highlighted option using pure estimate positions. use_effect(move || { if !render() { return; @@ -165,81 +113,81 @@ pub fn VirtualizedComboboxOptions(props: VirtualizedComboboxOptionsProps) -> Ele let Some(highlighted_index) = ctx.store.highlighted_option_index() else { return; }; - let visible_indices = visible_indices.peek(); - let Some(visible_index) = visible_indices - .iter() - .position(|index| *index == highlighted_index) - else { - return; + let visible_index = if let Some(indices) = props.visible_indices.as_ref() { + let indices = indices.read(); + let Some(pos) = indices.iter().position(|&i| i == highlighted_index) else { + return; + }; + pos + } else { + highlighted_index }; - let measurements = measurements.peek(); - let Some(item) = measurements - .iter() - .find(|item| item.index() == visible_index) - else { + let count = *visible_count.peek(); + if visible_index >= count { return; - }; - let start = item.start(); - let end = item.end(); - let current = *state.scroll_offset().peek(); - let viewport = *state.viewport_size().peek(); - let next = if start < current { - Some(start) - } else if end > current.saturating_add(viewport) { - Some(end.saturating_sub(viewport)) + } + let e = *est.peek(); + let item_start = visible_index as u32 * e; + let item_end = item_start + e; + let current = *scroll_offset.peek(); + let vp = *viewport_size.peek(); + let next = if item_start < current { + Some(item_start) + } else if item_end > current.saturating_add(vp) { + Some(item_end.saturating_sub(vp)) } else { None }; if let Some(next) = next { + scroll_offset.set(next); spawn(async move { sync_scroll(listbox.id.peek().clone(), next).await; }); - state.scroll_offset().set(next); } }); - let onresize = move |index| { - move |event: Event| { - let rect = event.data().get_content_box_size().unwrap_or_default(); - let measured = rect.height.max(1.0).round() as u32; - let measurements = measurements.peek(); - let adjustment = resize_item(&state, &measurements, index, measured); - drop(measurements); - if let Some(delta) = adjustment { - let current = *state.scroll_offset().peek(); - let next = (current as i32 + delta).max(0) as u32; - spawn(async move { - sync_scroll(listbox.id.peek().clone(), next).await; - }); - } - } - }; - - let measurements_read = measurements.read(); - let virtual_items = get_virtual_items(&state, &measurements_read, (props.buffer)()); - let viewport_size = *state.viewport_size().read(); - let virtual_items = if virtual_items.is_empty() && viewport_size == 0 { - let estimated_size = measurements_read - .first() - .map(|item| item.size()) - .filter(|size| *size > 0) - .unwrap_or(36); - let estimated_rows = (240 / estimated_size).max(1) as usize; - let bootstrap_count = measurements_read - .len() - .min(estimated_rows.saturating_add((props.buffer)())); - measurements_read - .iter() - .take(bootstrap_count) - .cloned() - .collect::>() - } else { - virtual_items - }; - let total_height = get_total_size(&state, &measurements_read); - let top_offset = virtual_items.first().map(|item| item.start()).unwrap_or(0); - let canvas_height = total_height.max(viewport_size); - let set_size = visible_indices.read().len().to_string(); + // ── Window calculation ──────────────────────────────────────────────────── + // + // The number of rendered DOM nodes MUST be stable during scroll. If it + // varies, Dioxus mounts/unmounts elements, which triggers browser layout + // recalculation and temporarily changes scrollHeight — making the thumb + // jump in size and position. + // + // Strategy (same as react-window / TanStack Virtual): + // 1. Compute `window_size` = rows_that_fit_in_viewport + 2 × buffer. + // This value is constant for a given viewport height. + // 2. Clamp `start` so that `start + window_size ≤ count`. This means + // near the end of the list we shift the window backward rather than + // letting it shrink — keeping the count fixed. + // 3. Each item is `position: absolute; transform: translateY(index * est)`. + // Items are NOT in normal document flow, so the canvas div's intrinsic + // height is zero — only the explicit `height: Xpx` CSS matters. + // `overflow: hidden` ensures no item can poke outside the canvas. + + let off = *scroll_offset.read(); + let vp = *viewport_size.read(); + let e = *est.read(); + let count = *visible_count.read(); + let buf = (props.buffer)(); + let e1 = e.max(1); + + // How many rows can the viewport hold? Use 240px as a stand-in before the + // first scroll event so the initial render is already fully populated. + let viewport_rows = if vp == 0 { 240 } else { vp }; + + // Fixed pool size — constant as long as viewport and buffer don't change. + let window_size = ((viewport_rows / e1) as usize + 2 * buf + 1).min(count); + + // Desired first visible row. + let desired_start = (off / e1).saturating_sub(buf as u32) as usize; + + // Clamp so we always emit exactly `window_size` items. At the bottom of + // the list this shifts the window backward instead of shrinking it. + let start = desired_start.min(count.saturating_sub(window_size)); + + // canvas_height = count × est. Fixed. Never changes during scroll. + let canvas_height = (count as u32 * e1).max(vp); + let set_size = count.to_string(); rsx! { if render() { @@ -247,48 +195,48 @@ pub fn VirtualizedComboboxOptions(props: VirtualizedComboboxOptionsProps) -> Ele id: listbox.id, role: "listbox", "data-state": if open() { "open" } else { "closed" }, + onmounted: on_mounted, + onscroll: on_scroll, onpointerdown: move |event| { event.prevent_default(); }, ..props.attributes, - div { - style: "position: relative; height:{canvas_height}px; width: 100%;", - div { - style: "position: absolute; inset: 0 auto auto 0; width: 100%; transform: translateY({top_offset}px); will-change: transform;", - {virtual_items.iter().map(move |item| { - let visible_index = item.index(); - let index = visible_indices.read()[visible_index]; - rsx! { - div { - key: "{item.key()}", - role: "presentation", - "data-virtual-index": "{index}", - "aria-setsize": "{set_size}", - "aria-posinset": "{visible_index + 1}", - onresize: onresize(visible_index), - {(props.render_option)(index)} + // Canvas: flex-shrink:0 is critical — the listbox is a flex column container, + // and without it the browser compresses this div to fit the max-height, + // eliminating overflow and making the list unscrollable. + div { style: "position: relative; overflow: hidden; flex-shrink: 0; height: {canvas_height}px; width: 100%;", + { + (start..start + window_size) + .map(move |visible_index| { + let index = props + .visible_indices + .as_ref() + .map(|indices| indices.read().get(visible_index).copied()) + .unwrap_or_else(|| { + (visible_index < count).then_some(visible_index) + }); + let item_top = visible_index as u32 * e1; + rsx! { + div { + key: "{visible_index}", + role: "presentation", + style: "position: absolute; top: 0; left: 0; width: 100%; transform: translateY({item_top}px);", + "data-virtual-index": "{visible_index}", + "aria-setsize": "{set_size}", + "aria-posinset": "{visible_index + 1}", + {index.map(|i| (props.render_option)(i))} + } } - } - })} + }) } } } } else { - {visible_indices.read().iter().map(move |index| rsx! { - {(props.render_option)(*index)} - })} + } } } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct VirtualScrollMsg { - offset: u32, - viewport: u32, - is_scrolling: bool, -} - async fn sync_scroll(container_id: String, scroll_top: u32) { let eval = document::eval( r#"