From 798ec9d02fad0645a85f8a2316f3e80c9a5d957d Mon Sep 17 00:00:00 2001 From: knox Date: Wed, 17 Dec 2025 18:10:27 +0100 Subject: [PATCH 1/3] Fix select options if disabled --- preview/src/components/select/style.css | 6 +- primitives/src/select/components/list.rs | 16 ++- primitives/src/select/components/option.rs | 6 +- primitives/src/select/components/select.rs | 4 +- primitives/src/select/components/trigger.rs | 4 +- primitives/src/select/context.rs | 115 +++++++++++++++++++- primitives/src/select/text_search.rs | 6 + 7 files changed, 139 insertions(+), 18 deletions(-) diff --git a/preview/src/components/select/style.css b/preview/src/components/select/style.css index 5a98dd3e..046d9a1d 100644 --- a/preview/src/components/select/style.css +++ b/preview/src/components/select/style.css @@ -133,6 +133,7 @@ .select-option[data-disabled="true"] { color: var(--secondary-color-5); cursor: not-allowed; + opacity: 0.5; } .select-option:hover:not([data-disabled="true"]), @@ -148,8 +149,3 @@ color: var(--secondary-color-5); font-size: 0.75rem; } - -[data-disabled="true"] { - cursor: not-allowed; - opacity: 0.5; -} diff --git a/primitives/src/select/components/list.rs b/primitives/src/select/components/list.rs index 019d9318..325090db 100644 --- a/primitives/src/select/components/list.rs +++ b/primitives/src/select/components/list.rs @@ -125,19 +125,19 @@ pub fn SelectList(props: SelectListProps) -> Element { } Key::ArrowUp => { arrow_key_navigation(event); - ctx.focus_state.focus_prev(); + ctx.focus_prev(); } Key::End => { arrow_key_navigation(event); - ctx.focus_state.focus_last(); + ctx.focus_last(); } Key::ArrowDown => { arrow_key_navigation(event); - ctx.focus_state.focus_next(); + ctx.focus_next(); } Key::Home => { arrow_key_navigation(event); - ctx.focus_state.focus_first(); + ctx.focus_first(); } Key::Enter => { ctx.select_current_item(); @@ -163,9 +163,13 @@ pub fn SelectList(props: SelectListProps) -> Element { use_effect(move || { if render() { - ctx.focus_state.set_focus(ctx.initial_focus.cloned()); + if (ctx.initial_focus_last)().unwrap_or_default() { + ctx.focus_last(); + } else { + ctx.focus_first(); + } } else { - ctx.initial_focus.set(None); + ctx.initial_focus_last.set(None); } }); diff --git a/primitives/src/select/components/option.rs b/primitives/src/select/components/option.rs index 7b2a33cd..a1059789 100644 --- a/primitives/src/select/components/option.rs +++ b/primitives/src/select/components/option.rs @@ -129,12 +129,14 @@ pub fn SelectOption(props: SelectOptionProps) // Push this option to the context let mut ctx: SelectContext = use_context(); + let disabled = ctx.disabled.cloned() || props.disabled.cloned(); use_effect(move || { let option_state = OptionState { tab_index: index(), value: RcPartialEqValue::new(value.cloned()), text_value: text_value.cloned(), id: id(), + disabled }; // Add the option to the context's options @@ -147,7 +149,6 @@ pub fn SelectOption(props: SelectOptionProps) let onmounted = use_focus_controlled_item(props.index); let focused = move || ctx.focus_state.is_focused(index()); - let disabled = ctx.disabled.cloned() || props.disabled.cloned(); let selected = use_memo(move || { ctx.value.read().as_ref().and_then(|v| v.as_ref::()) == Some(&props.value.read()) }); @@ -172,6 +173,9 @@ pub fn SelectOption(props: SelectOptionProps) aria_label: props.aria_label.clone(), aria_roledescription: props.aria_roledescription.clone(), + // data attributes + "data-disabled": disabled, + onpointerdown: move |event| { if !disabled && event.trigger_button() == Some(MouseButton::Primary) { ctx.set_value.call(Some(RcPartialEqValue::new(props.value.cloned()))); diff --git a/primitives/src/select/components/select.rs b/primitives/src/select/components/select.rs index 6844b6c2..f0fafb77 100644 --- a/primitives/src/select/components/select.rs +++ b/primitives/src/select/components/select.rs @@ -143,7 +143,7 @@ pub fn Select(props: SelectProps) -> Element typeahead_buffer.take(); } }); - let initial_focus = use_signal(|| None); + let initial_focus_last = use_signal(|| None); use_context_provider(|| SelectContext { typeahead_buffer, @@ -158,7 +158,7 @@ pub fn Select(props: SelectProps) -> Element placeholder: props.placeholder, typeahead_clear_task, typeahead_timeout: props.typeahead_timeout, - initial_focus, + initial_focus_last, }); rsx! { diff --git a/primitives/src/select/components/trigger.rs b/primitives/src/select/components/trigger.rs index f6162f6a..82ff4474 100644 --- a/primitives/src/select/components/trigger.rs +++ b/primitives/src/select/components/trigger.rs @@ -82,13 +82,13 @@ pub fn SelectTrigger(props: SelectTriggerProps) -> Element { match event.key() { Key::ArrowUp => { open.set(true); - ctx.initial_focus.set(ctx.focus_state.item_count().checked_sub(1)); + ctx.initial_focus_last.set(Some(true)); event.prevent_default(); event.stop_propagation(); } Key::ArrowDown => { open.set(true); - ctx.initial_focus.set((ctx.focus_state.item_count() > 0).then_some(0)); + ctx.initial_focus_last.set(Some(false)); event.prevent_default(); event.stop_propagation(); } diff --git a/primitives/src/select/context.rs b/primitives/src/select/context.rs index 94a3aa6c..fb00c1b1 100644 --- a/primitives/src/select/context.rs +++ b/primitives/src/select/context.rs @@ -73,11 +73,119 @@ pub(super) struct SelectContext { pub typeahead_clear_task: Signal>, /// Timeout before clearing typeahead buffer pub typeahead_timeout: ReadSignal, - /// The initial element to focus once the list is rendered - pub initial_focus: Signal>, + + /// The initial element to focus once the list is rendered
+ /// true: last element
+ /// false: first element + pub initial_focus_last: Signal>, } impl SelectContext { + /// custom implementation for `FocusState::focus_next` + pub(crate) fn focus_next(&mut self) { + let current_focus = self.focus_state.recent_focus(); + let mut new_focus = current_focus.unwrap_or_default(); + let start_focus = current_focus.unwrap_or_default(); + let item_count = (self.focus_state.item_count)(); + let roving_loop = (self.focus_state.roving_loop)(); + let options = self.options.read(); + + loop { + new_focus = new_focus.saturating_add(1); + if new_focus >= item_count { + new_focus = match roving_loop { + true => 0, + false => item_count.saturating_sub(1), + } + } + + // get value of the option + let disabled = options.iter().find(|opt| opt.tab_index == new_focus).map(|e| e.disabled).unwrap_or(false); + + // this fails if the current_focus at the start is None + if !disabled || new_focus == start_focus { + break; + } + } + + self.focus_state.set_focus(Some(new_focus)); + } + + /// custom implementation for `FocusState::focus_prev` + pub(crate) fn focus_prev(&mut self) { + let current_focus = self.focus_state.recent_focus(); + let mut new_focus = current_focus.unwrap_or_default(); + let start_focus = current_focus.unwrap_or_default(); + let item_count = (self.focus_state.item_count)(); + let roving_loop = (self.focus_state.roving_loop)(); + let options = self.options.read(); + + loop { + let old_focus = new_focus; + new_focus = new_focus.saturating_sub(1); + + if old_focus == 0 && roving_loop { + new_focus = item_count.saturating_sub(1); + } + + // get value of the option + let disabled = options.iter().find(|opt| opt.tab_index == new_focus).map(|e| e.disabled).unwrap_or(false); + + if !disabled || new_focus == start_focus { + break; + } + } + + self.focus_state.set_focus(Some(new_focus)); + } + + /// custom implementation for `FocusState::focus_last` + pub(crate) fn focus_last(&mut self) { + let item_count = (self.focus_state.item_count)(); + let options = self.options.read(); + let mut new_focus = item_count; + + loop { + // If at the start, don't focus anything + if new_focus == 0 { + return; + } + new_focus = new_focus.saturating_sub(1); + + // get value of the option + let disabled = options.iter().find(|opt| opt.tab_index == new_focus).map(|e| e.disabled).unwrap_or(false); + + if !disabled { + break; + } + } + self.focus_state.set_focus(Some(new_focus)); + } + + /// custom implementation for `FocusState::focus_first` + pub(crate) fn focus_first(&mut self) { + let item_count = (self.focus_state.item_count)(); + let options = self.options.read(); + let mut new_focus = 0; + + loop { + // get value of the option + let disabled = options.iter().find(|opt| opt.tab_index == new_focus).map(|e| e.disabled).unwrap_or(false); + + if !disabled { + break; + } + + // If at the end, don't focus anything + if new_focus >= item_count { + return; + } + + new_focus = new_focus.saturating_add(1); + } + self.focus_state.set_focus(Some(new_focus)); + } + /// Select the currently focused item pub fn select_current_item(&mut self) { // If the select is open, select the focused item @@ -154,6 +262,9 @@ pub(super) struct OptionState { pub text_value: String, /// Unique ID for the option pub id: String, + + /// Whether the option is disabled + pub disabled: bool, } /// Context for select option components to know if they're selected diff --git a/primitives/src/select/text_search.rs b/primitives/src/select/text_search.rs index bc311b96..24d80211 100644 --- a/primitives/src/select/text_search.rs +++ b/primitives/src/select/text_search.rs @@ -18,6 +18,7 @@ pub(super) fn best_match( options .iter() + .filter(|o| !o.disabled) .map(|opt| { let value = &opt.text_value; let value_characters: Box<[_]> = value.chars().collect(); @@ -539,18 +540,21 @@ mod tests { value: RcPartialEqValue::new("apple"), text_value: "Apple".to_string(), id: "apple".to_string(), + disabled: false, }, OptionState { tab_index: 1, value: RcPartialEqValue::new("banana"), text_value: "Banana".to_string(), id: "banana".to_string(), + disabled: false, }, OptionState { tab_index: 2, value: RcPartialEqValue::new("cherry"), text_value: "Cherry".to_string(), id: "cherry".to_string(), + disabled: false, }, ]; @@ -605,12 +609,14 @@ mod tests { value: RcPartialEqValue::new("ф"), text_value: "ф".to_string(), id: "ф".to_string(), + disabled: false, }, OptionState { tab_index: 1, value: RcPartialEqValue::new("banana"), text_value: "Banana".to_string(), id: "banana".to_string(), + disabled: false, }, ]; From 94a325799ddee2ff456cb1a8371ce9d7981baa25 Mon Sep 17 00:00:00 2001 From: knox Date: Fri, 19 Dec 2025 17:26:45 +0100 Subject: [PATCH 2/3] rework focus handling to allow non consecutive and out of order indexes --- primitives/src/select/components/list.rs | 2 +- primitives/src/select/components/option.rs | 35 +++- primitives/src/select/components/select.rs | 10 +- primitives/src/select/components/value.rs | 6 +- primitives/src/select/context.rs | 186 +++++++++++---------- primitives/src/select/text_search.rs | 17 +- 6 files changed, 143 insertions(+), 113 deletions(-) diff --git a/primitives/src/select/components/list.rs b/primitives/src/select/components/list.rs index 325090db..f75b405a 100644 --- a/primitives/src/select/components/list.rs +++ b/primitives/src/select/components/list.rs @@ -81,7 +81,7 @@ pub fn SelectList(props: SelectListProps) -> Element { let mut open = ctx.open; let mut listbox_ref: Signal>> = use_signal(|| None); - let focused = move || open() && !ctx.focus_state.any_focused(); + let focused = move || open() && !ctx.any_focused(); use_effect(move || { let Some(listbox_ref) = listbox_ref() else { diff --git a/primitives/src/select/components/option.rs b/primitives/src/select/components/option.rs index a1059789..39f78ebb 100644 --- a/primitives/src/select/components/option.rs +++ b/primitives/src/select/components/option.rs @@ -1,12 +1,12 @@ //! SelectOption and SelectItemIndicator component implementations. use crate::{ - focus::use_focus_controlled_item, select::context::{RcPartialEqValue, SelectListContext}, use_effect, use_effect_cleanup, use_id_or, use_unique_id, }; use dioxus::html::input_data::MouseButton; use dioxus::prelude::*; +use std::rc::Rc; use super::super::context::{OptionState, SelectContext, SelectOptionContext}; @@ -28,7 +28,7 @@ pub struct SelectOptionProps { #[props(default)] pub id: ReadSignal>, - /// The index of the option in the list. This is used to define the focus order for keyboard navigation. + /// The index of the option in the list. This is used to define the focus order for keyboard navigation. Each option must have a unique index. pub index: ReadSignal, /// Optional label for the option (for accessibility) @@ -136,19 +136,36 @@ pub fn SelectOption(props: SelectOptionProps) value: RcPartialEqValue::new(value.cloned()), text_value: text_value.cloned(), id: id(), - disabled + disabled, }; // Add the option to the context's options - ctx.options.write().push(option_state); + ctx.options + .write() + .insert(option_state.tab_index, option_state); }); use_effect_cleanup(move || { - ctx.options.write().retain(|opt| opt.id != *id.read()); + ctx.options.write().remove(&index()); }); - let onmounted = use_focus_controlled_item(props.index); - let focused = move || ctx.focus_state.is_focused(index()); + // customized focus handle for this option. Based on `use_focus_controlled_item`. + let mut controlled_ref: Signal>> = use_signal(|| None); + use_effect(move || { + if disabled { + return; + } + let is_focused = ctx.is_focused(index.cloned()); + if is_focused { + if let Some(md) = controlled_ref() { + spawn(async move { + let _ = md.set_focus(true).await; + }); + } + } + }); + + let focused = move || ctx.is_focused(index()); let selected = use_memo(move || { ctx.value.read().as_ref().and_then(|v| v.as_ref::()) == Some(&props.value.read()) }); @@ -165,7 +182,7 @@ pub fn SelectOption(props: SelectOptionProps) role: "option", id, tabindex: if focused() { "0" } else { "-1" }, - onmounted, + onmounted: move |data: Event| controlled_ref.set(Some(data.data())), // ARIA attributes aria_selected: selected(), @@ -184,7 +201,7 @@ pub fn SelectOption(props: SelectOptionProps) }, onblur: move |_| { if focused() { - ctx.focus_state.blur(); + ctx.blur(); ctx.open.set(false); } }, diff --git a/primitives/src/select/components/select.rs b/primitives/src/select/components/select.rs index f0fafb77..602809b1 100644 --- a/primitives/src/select/components/select.rs +++ b/primitives/src/select/components/select.rs @@ -1,6 +1,7 @@ //! Main Select component implementation. use core::panic; +use std::collections::BTreeMap; use std::time::Duration; use crate::{select::context::RcPartialEqValue, use_controlled, use_effect}; @@ -8,7 +9,6 @@ use dioxus::prelude::*; use dioxus_core::Task; use super::super::context::SelectContext; -use crate::focus::use_focus_provider; /// Props for the main Select component #[derive(Props, Clone, PartialEq)] @@ -109,7 +109,7 @@ pub fn Select(props: SelectProps) -> Element let open = use_signal(|| false); let mut typeahead_buffer = use_signal(String::new); - let options = use_signal(Vec::default); + let options = use_signal(BTreeMap::new); let adaptive_keyboard = use_signal(super::super::text_search::AdaptiveKeyboard::new); let list_id = use_signal(|| None); let mut typeahead_clear_task: Signal> = use_signal(|| None); @@ -130,8 +130,6 @@ pub fn Select(props: SelectProps) -> Element } }); - let focus_state = use_focus_provider(props.roving_loop); - // Clear the typeahead buffer when the select is closed use_effect(move || { if !open() { @@ -144,6 +142,7 @@ pub fn Select(props: SelectProps) -> Element } }); let initial_focus_last = use_signal(|| None); + let current_focus = use_signal(|| None); use_context_provider(|| SelectContext { typeahead_buffer, @@ -151,14 +150,15 @@ pub fn Select(props: SelectProps) -> Element value, set_value, options, + roving_loop: props.roving_loop, adaptive_keyboard, list_id, - focus_state, disabled: props.disabled, placeholder: props.placeholder, typeahead_clear_task, typeahead_timeout: props.typeahead_timeout, initial_focus_last, + current_focus, }); rsx! { diff --git a/primitives/src/select/components/value.rs b/primitives/src/select/components/value.rs index 2fa55930..4f9fc4c3 100644 --- a/primitives/src/select/components/value.rs +++ b/primitives/src/select/components/value.rs @@ -70,9 +70,9 @@ pub fn SelectValue(props: SelectValueProps) -> Element { value.as_ref().and_then(|v| { ctx.options .read() - .iter() - .find(|opt| opt.value == *v) - .map(|opt| opt.text_value.clone()) + .values() + .find(|state| state.value == *v) + .map(|state| state.text_value.clone()) }) }); diff --git a/primitives/src/select/context.rs b/primitives/src/select/context.rs index fb00c1b1..ac6999a5 100644 --- a/primitives/src/select/context.rs +++ b/primitives/src/select/context.rs @@ -1,13 +1,12 @@ //! Context types and implementations for the select component. -use crate::focus::FocusState; use dioxus::prelude::*; use dioxus_core::Task; use dioxus_sdk_time::sleep; -use std::{any::Any, rc::Rc, time::Duration}; - use super::text_search::AdaptiveKeyboard; +use std::collections::BTreeMap; +use std::{any::Any, rc::Rc, time::Duration}; trait DynPartialEq: Any { fn eq(&self, other: &dyn Any) -> bool; @@ -57,14 +56,10 @@ pub(super) struct SelectContext { pub value: Memo>, /// Set the value callback pub set_value: Callback>, - /// A list of options with their states - pub options: Signal>, /// Adaptive keyboard system for multi-language support pub adaptive_keyboard: Signal, /// The ID of the list for ARIA attributes pub list_id: Signal>, - /// The focus state for the select - pub focus_state: FocusState, /// Whether the select is disabled pub disabled: ReadSignal, /// The placeholder text @@ -73,7 +68,12 @@ pub(super) struct SelectContext { pub typeahead_clear_task: Signal>, /// Timeout before clearing typeahead buffer pub typeahead_timeout: ReadSignal, - + /// A list of options with their states + pub options: Signal>, + /// If focus should loop around + pub roving_loop: ReadSignal, + /// The currently selected option tab_index + pub current_focus: Signal>, /// The initial element to focus once the list is rendered
/// true: last element
/// false: first element @@ -81,119 +81,134 @@ pub(super) struct SelectContext { } impl SelectContext { + /// custom implementation for `FocusState::is_selected` + pub(crate) fn is_focused(&self, id: usize) -> bool { + (self.current_focus)() == Some(id) + } + + pub(crate) fn any_focused(&self) -> bool { + self.current_focus.read().is_some() + } + + pub(crate) fn current_focus(&self) -> Option { + (self.current_focus)() + } + + pub(crate) fn blur(&mut self) { + self.current_focus.write().take(); + } + /// custom implementation for `FocusState::focus_next` pub(crate) fn focus_next(&mut self) { - let current_focus = self.focus_state.recent_focus(); - let mut new_focus = current_focus.unwrap_or_default(); - let start_focus = current_focus.unwrap_or_default(); - let item_count = (self.focus_state.item_count)(); - let roving_loop = (self.focus_state.roving_loop)(); + // select first if current is none + let current_focus = match self.current_focus() { + Some(k) => k, + None => return self.focus_first(), + }; + let options = self.options.read(); - loop { - new_focus = new_focus.saturating_add(1); - if new_focus >= item_count { - new_focus = match roving_loop { - true => 0, - false => item_count.saturating_sub(1), - } + // iterate until the end of the map + for (index, state) in options.range((current_focus + 1)..) { + // focus if not disabled + if !state.disabled { + self.current_focus.set(Some(*index)); + return; } + } - // get value of the option - let disabled = options.iter().find(|opt| opt.tab_index == new_focus).map(|e| e.disabled).unwrap_or(false); + // stop if we dont allow rollover + if !(self.roving_loop)() { + return; + } - // this fails if the current_focus at the start is None - if !disabled || new_focus == start_focus { + // iterate over the rest of the map starting from the beginning + for (index, state) in options.range(..=current_focus) { + // stop if we reached the current element + if *index == current_focus { break; } - } - self.focus_state.set_focus(Some(new_focus)); + // focus if not disabled + if !state.disabled { + self.current_focus.set(Some(*index)); + return; + } + } } /// custom implementation for `FocusState::focus_prev` pub(crate) fn focus_prev(&mut self) { - let current_focus = self.focus_state.recent_focus(); - let mut new_focus = current_focus.unwrap_or_default(); - let start_focus = current_focus.unwrap_or_default(); - let item_count = (self.focus_state.item_count)(); - let roving_loop = (self.focus_state.roving_loop)(); - let options = self.options.read(); + // focus last if current is none + let current_focus = match self.current_focus() { + Some(k) => k, + None => return self.focus_last(), + }; - loop { - let old_focus = new_focus; - new_focus = new_focus.saturating_sub(1); + let options = self.options.read(); - if old_focus == 0 && roving_loop { - new_focus = item_count.saturating_sub(1); + // iterate until the start of the map (reversed) + for (index, state) in options.range(..current_focus).rev() { + // focus if not disabled + if !state.disabled { + self.current_focus.set(Some(*index)); + return; } + } - // get value of the option - let disabled = options.iter().find(|opt| opt.tab_index == new_focus).map(|e| e.disabled).unwrap_or(false); + // stop if we dont allow rollover + if !(self.roving_loop)() { + return; + } - if !disabled || new_focus == start_focus { + // iterate over the rest of the map starting from the end (reversed) + for (index, state) in options.range(current_focus..).rev() { + // stop if we reached the current element + if *index == current_focus { break; } - } - - self.focus_state.set_focus(Some(new_focus)); - } - - /// custom implementation for `FocusState::focus_last` - pub(crate) fn focus_last(&mut self) { - let item_count = (self.focus_state.item_count)(); - let options = self.options.read(); - let mut new_focus = item_count; - loop { - // If at the start, don't focus anything - if new_focus == 0 { + // focus if not disabled + if !state.disabled { + self.current_focus.set(Some(*index)); return; } - new_focus = new_focus.saturating_sub(1); - - // get value of the option - let disabled = options.iter().find(|opt| opt.tab_index == new_focus).map(|e| e.disabled).unwrap_or(false); - - if !disabled { - break; - } } - self.focus_state.set_focus(Some(new_focus)); } /// custom implementation for `FocusState::focus_first` pub(crate) fn focus_first(&mut self) { - let item_count = (self.focus_state.item_count)(); - let options = self.options.read(); - let mut new_focus = 0; - - loop { - // get value of the option - let disabled = options.iter().find(|opt| opt.tab_index == new_focus).map(|e| e.disabled).unwrap_or(false); - - if !disabled { - break; - } - - // If at the end, don't focus anything - if new_focus >= item_count { - return; - } + if let Some((index, _)) = self + .options + .read() + .iter() + .find(|(_, state)| !state.disabled) + { + self.current_focus.set(Some(*index)); + } + } - new_focus = new_focus.saturating_add(1); + /// custom implementation for `FocusState::focus_last` + pub(crate) fn focus_last(&mut self) { + if let Some((index, _)) = self + .options + .read() + .iter() + .rev() + .find(|(_, state)| !state.disabled) + { + self.current_focus.set(Some(*index)); } - self.focus_state.set_focus(Some(new_focus)); } /// Select the currently focused item pub fn select_current_item(&mut self) { // If the select is open, select the focused item if self.open.cloned() { - if let Some(focused_index) = self.focus_state.current_focus() { + if let Some(focused_index) = self.current_focus() { let options = self.options.read(); - if let Some(option) = options.iter().find(|opt| opt.tab_index == focused_index) { - self.set_value.call(Some(option.value.clone())); + if let Some(state) = options.get(&focused_index) { + self.set_value.call(Some(state.value.clone())); self.open.set(false); } } @@ -245,9 +260,9 @@ impl SelectContext { let keyboard = self.adaptive_keyboard.read(); if let Some(best_match_index) = - super::text_search::best_match(&keyboard, &typeahead, &options) + super::text_search::best_match(&keyboard, &typeahead, options.values()) { - self.focus_state.set_focus(Some(best_match_index)); + self.current_focus.set(Some(best_match_index)); } } } @@ -262,7 +277,6 @@ pub(super) struct OptionState { pub text_value: String, /// Unique ID for the option pub id: String, - /// Whether the option is disabled pub disabled: bool, } diff --git a/primitives/src/select/text_search.rs b/primitives/src/select/text_search.rs index 24d80211..2907e754 100644 --- a/primitives/src/select/text_search.rs +++ b/primitives/src/select/text_search.rs @@ -5,10 +5,10 @@ use core::f32; use std::collections::HashMap; /// Find the best matching option based on typeahead input -pub(super) fn best_match( +pub(super) fn best_match<'a>( keyboard: &AdaptiveKeyboard, typeahead: &str, - options: &[OptionState], + options: impl Iterator, ) -> Option { if typeahead.is_empty() { return None; @@ -17,7 +17,6 @@ pub(super) fn best_match( let typeahead_characters: Box<[_]> = typeahead.chars().collect(); options - .iter() .filter(|o| !o.disabled) .map(|opt| { let value = &opt.text_value; @@ -561,19 +560,19 @@ mod tests { let layout = AdaptiveKeyboard::default(); // Exact prefix match - let result = best_match(&layout, "App", &options); + let result = best_match(&layout, "App", options.iter()); assert_eq!(result, Some(0)); // Partial match - let result = best_match(&layout, "ban", &options); + let result = best_match(&layout, "ban", options.iter()); assert_eq!(result, Some(1)); // Empty typeahead should return None - let result = best_match(&layout, "", &options); + let result = best_match(&layout, "", options.iter()); assert_eq!(result, None); // No match should return closest option - let result = best_match(&layout, "xyz", &options); + let result = best_match(&layout, "xyz", options.iter()); assert!(result.is_some()); } @@ -621,11 +620,11 @@ mod tests { ]; // ы should be a closer match to ф than banana - let result = best_match(&adaptive, "ф", &options); + let result = best_match(&adaptive, "ф", options.iter()); assert_eq!(result, Some(0)); // b should still match banana - let result = best_match(&adaptive, "b", &options); + let result = best_match(&adaptive, "b", options.iter()); assert_eq!(result, Some(1)); } From 87816dc158d5cfdc6025bce4e59eef36ac3d63cb Mon Sep 17 00:00:00 2001 From: knox Date: Mon, 22 Dec 2025 18:56:42 +0100 Subject: [PATCH 3/3] select: more tests, some bug fixes and disabled example --- playwright/select.spec.ts | 127 ++++++++++++++++-- preview/src/components/mod.rs | 2 +- .../select/variants/disabled/mod.rs | 71 ++++++++++ .../components/select/variants/main/mod.rs | 2 +- primitives/src/select/components/group.rs | 2 +- primitives/src/select/components/list.rs | 10 +- primitives/src/select/components/option.rs | 7 +- primitives/src/select/components/trigger.rs | 6 +- primitives/src/select/components/value.rs | 6 +- primitives/src/select/context.rs | 9 +- 10 files changed, 213 insertions(+), 29 deletions(-) create mode 100644 preview/src/components/select/variants/disabled/mod.rs diff --git a/playwright/select.spec.ts b/playwright/select.spec.ts index cb612ebc..813b6522 100644 --- a/playwright/select.spec.ts +++ b/playwright/select.spec.ts @@ -5,10 +5,10 @@ test("test", async ({ page }) => { timeout: 20 * 60 * 1000, }); // Increase timeout to 20 minutes // Find Select a fruit... - let selectTrigger = page.locator(".select-trigger"); + let selectTrigger = page.locator("#select-main .select-trigger"); await selectTrigger.click(); // Assert the select menu is open - const selectMenu = page.locator(".select-list"); + const selectMenu = page.locator("#select-main .select-list"); await expect(selectMenu).toHaveAttribute("data-state", "open"); // Assert the menu is focused @@ -64,10 +64,10 @@ test("test", async ({ page }) => { test("tabbing out of menu closes the select menu", async ({ page }) => { await page.goto("http://127.0.0.1:8080/component/?name=select&"); // Find Select a fruit... - let selectTrigger = page.locator(".select-trigger"); + let selectTrigger = page.locator("#select-main .select-trigger"); await selectTrigger.click(); // Assert the select menu is open - const selectMenu = page.locator(".select-list"); + const selectMenu = page.locator("#select-main .select-list"); await expect(selectMenu).toHaveAttribute("data-state", "open"); // Assert the menu is focused @@ -80,10 +80,10 @@ test("tabbing out of menu closes the select menu", async ({ page }) => { test("tabbing out of item closes the select menu", async ({ page }) => { await page.goto("http://127.0.0.1:8080/component/?name=select&"); // Find Select a fruit... - let selectTrigger = page.locator(".select-trigger"); + let selectTrigger = page.locator("#select-main .select-trigger"); await selectTrigger.click(); // Assert the select menu is open - const selectMenu = page.locator(".select-list"); + const selectMenu = page.locator("#select-main .select-list"); await expect(selectMenu).toHaveAttribute("data-state", "open"); // Assert the menu is focused @@ -101,10 +101,10 @@ test("tabbing out of item closes the select menu", async ({ page }) => { test("options selected", async ({ page }) => { await page.goto("http://127.0.0.1:8080/component/?name=select&"); // Find Select a fruit... - let selectTrigger = page.locator(".select-trigger"); + let selectTrigger = page.locator("#select-main .select-trigger"); await selectTrigger.click(); // Assert the select menu is open - const selectMenu = page.locator(".select-list"); + const selectMenu = page.locator("#select-main .select-list"); await expect(selectMenu).toHaveAttribute("data-state", "open"); // Assert no items have aria-selected @@ -130,25 +130,126 @@ test("options selected", async ({ page }) => { test("down arrow selects first element", async ({ page }) => { await page.goto("http://127.0.0.1:8080/component/?name=select&"); // Find Select a fruit... - let selectTrigger = page.locator(".select-trigger"); - const selectMenu = page.locator(".select-list"); + let selectTrigger = page.locator("#select-main .select-trigger"); + const selectMenu = page.locator("#select-main .select-list"); await selectTrigger.focus(); // Select the first option await page.keyboard.press("ArrowDown"); const firstOption = selectMenu.getByRole("option", { name: "apple" }); await expect(firstOption).toBeFocused(); + + // Same thing but with the first option disabled + let disabledSelectTrigger = page.locator("#select-disabled .select-trigger"); + const disabledSelectMenu = page.locator("#select-disabled .select-list"); + await disabledSelectTrigger.focus(); + await page.keyboard.press("ArrowDown"); + const disabledFirstOption = disabledSelectMenu.getByRole("option", { name: "banana" }); + await expect(disabledFirstOption).toBeFocused(); }); test("up arrow selects last element", async ({ page }) => { await page.goto("http://127.0.0.1:8080/component/?name=select&"); // Find Select a fruit... - let selectTrigger = page.locator(".select-trigger"); - const selectMenu = page.locator(".select-list"); + let selectTrigger = page.locator("#select-main .select-trigger"); + const selectMenu = page.locator("#select-main .select-list"); await selectTrigger.focus(); // Select the first option await page.keyboard.press("ArrowUp"); - const firstOption = selectMenu.getByRole("option", { name: "other" }); + const lastOption = selectMenu.getByRole("option", { name: "other" }); + await expect(lastOption).toBeFocused(); + + // Same thing but with the last option disabled + let disabledSelectTrigger = page.locator("#select-disabled .select-trigger"); + const disabledSelectMenu = page.locator("#select-disabled .select-list"); + await disabledSelectTrigger.focus(); + + await page.keyboard.press("ArrowUp"); + const disabledLastOption = disabledSelectMenu.getByRole("option", { name: "watermelon" }); + await expect(disabledLastOption).toBeFocused(); +}); + +test("rollover on top and bottom", async ({ page }) => { + await page.goto("http://127.0.0.1:8080/component/?name=select&"); + + // Find Select a fruit... + let selectTrigger = page.locator("#select-main .select-trigger"); + const selectMenu = page.locator("#select-main .select-list"); + await selectTrigger.focus(); + + // open the list and select first option + await page.keyboard.press("ArrowDown"); + const firstOption = selectMenu.getByRole("option", { name: "apple" }); await expect(firstOption).toBeFocused(); + + // up arrow to select last option (rollover) + await page.keyboard.press("ArrowUp"); + const lastOption = selectMenu.getByRole("option", { name: "other" }); + await expect(lastOption).toBeFocused(); + + // down arrow to select first option (rollover) + await page.keyboard.press("ArrowDown"); + await expect(firstOption).toBeFocused(); + + // Same thing but with first and last options disabled + let disabledSelectTrigger = page.locator("#select-disabled .select-trigger"); + const disabledSelectMenu = page.locator("#select-disabled .select-list"); + await disabledSelectTrigger.focus(); + + // open the list and select first option + await page.keyboard.press("ArrowDown"); + const disabledFirstOption = disabledSelectMenu.getByRole("option", { name: "banana" }); + await expect(disabledFirstOption).toBeFocused(); + + // up arrow to select last option (rollover) + await page.keyboard.press("ArrowUp"); + const disabledLastOption = disabledSelectMenu.getByRole("option", { name: "watermelon" }); + await expect(disabledLastOption).toBeFocused(); + + // down arrow to select first option (rollover) + await page.keyboard.press("ArrowDown"); + await expect(disabledFirstOption).toBeFocused(); +}); + +test("disabled elements are skipped", async ({ page }) => { + await page.goto("http://127.0.0.1:8080/component/?name=select&"); + + // Find Select a fruit... + let selectTrigger = page.locator("#select-disabled .select-trigger"); + const selectMenu = page.locator("#select-disabled .select-list"); + await selectTrigger.focus(); + + // open the list and select first enabled option + await page.keyboard.press("ArrowDown"); + const firstOption = selectMenu.getByRole("option", { name: "banana" }); + await expect(firstOption).toBeFocused(); + + // down arrow to select second enabled option + await page.keyboard.press("ArrowDown"); + const secondOption = selectMenu.getByRole("option", { name: "strawberry" }); + await expect(secondOption).toBeFocused(); + + // up arrow to select first enabled option + await page.keyboard.press("ArrowUp"); + await expect(firstOption).toBeFocused(); +}); + +test("aria active descendant", async ({ page }) => { + await page.goto("http://127.0.0.1:8080/component/?name=select&"); + + // Find Select a fruit... + let selectTrigger = page.locator("#select-main .select-trigger"); + const selectMenu = page.locator("#select-main .select-list"); + await selectTrigger.focus(); + + // select first option + await page.keyboard.press("ArrowDown"); + const firstOption = selectMenu.getByRole("option", { name: "apple" }); + await expect(selectTrigger).toHaveAttribute("aria-activedescendant", await firstOption.getAttribute("id")); + + // select second option + await page.keyboard.press("ArrowDown"); + const secondOption = selectMenu.getByRole("option", { name: "banana" }); + await expect(selectTrigger).toHaveAttribute("aria-activedescendant", await secondOption.getAttribute("id")); }); diff --git a/preview/src/components/mod.rs b/preview/src/components/mod.rs index 8fb5d326..1fe95ab8 100644 --- a/preview/src/components/mod.rs +++ b/preview/src/components/mod.rs @@ -79,7 +79,7 @@ examples!( progress, radio_group, scroll_area, - select, + select[disabled], separator, skeleton, sheet, diff --git a/preview/src/components/select/variants/disabled/mod.rs b/preview/src/components/select/variants/disabled/mod.rs new file mode 100644 index 00000000..9739f32d --- /dev/null +++ b/preview/src/components/select/variants/disabled/mod.rs @@ -0,0 +1,71 @@ +use super::super::component::*; +use dioxus::prelude::*; +use strum::{EnumCount, IntoEnumIterator}; + +#[derive(Debug, Clone, Copy, PartialEq, strum::EnumCount, strum::EnumIter, strum::Display)] +enum Fruit { + Apple, + Banana, + Orange, + Strawberry, + Watermelon, +} + +impl Fruit { + const fn emoji(&self) -> &'static str { + match self { + Fruit::Apple => "🍎", + Fruit::Banana => "🍌", + Fruit::Orange => "🍊", + Fruit::Strawberry => "🍓", + Fruit::Watermelon => "🍉", + } + } + + const fn disabled(&self) -> bool { + match self { + Fruit::Apple => true, + Fruit::Orange => true, + _ => false + } + } +} + +#[component] +pub fn Demo() -> Element { + let fruits = Fruit::iter().enumerate().map(|(i, f)| { + rsx! { + SelectOption::> { + index: i, + value: f, + text_value: "{f}", + disabled: f.disabled(), + {format!("{} {f}", f.emoji())} + SelectItemIndicator {} + } + } + }); + + rsx! { + Select::> { id: "select-disabled", placeholder: "Select a fruit...", + SelectTrigger { aria_label: "Select Trigger", width: "12rem", SelectValue {} } + SelectList { aria_label: "Select Demo", + SelectGroup { + SelectGroupLabel { "Fruits" } + {fruits} + } + SelectGroup { + SelectGroupLabel { "Other" } + SelectOption::> { + index: Fruit::COUNT, + value: None, + text_value: "Other", + disabled: true, + "Other" + SelectItemIndicator {} + } + } + } + } + } +} diff --git a/preview/src/components/select/variants/main/mod.rs b/preview/src/components/select/variants/main/mod.rs index 73f93e4d..7f5386f9 100644 --- a/preview/src/components/select/variants/main/mod.rs +++ b/preview/src/components/select/variants/main/mod.rs @@ -36,7 +36,7 @@ pub fn Demo() -> Element { rsx! { - Select::> { placeholder: "Select a fruit...", + Select::> { id: "select-main", placeholder: "Select a fruit...", SelectTrigger { aria_label: "Select Trigger", width: "12rem", SelectValue {} } SelectList { aria_label: "Select Demo", SelectGroup { diff --git a/primitives/src/select/components/group.rs b/primitives/src/select/components/group.rs index d8fcb0dd..53aaedb2 100644 --- a/primitives/src/select/components/group.rs +++ b/primitives/src/select/components/group.rs @@ -173,7 +173,7 @@ pub fn SelectGroupLabel(props: SelectGroupLabelProps) -> Element { let render = use_context::().render; rsx! { - if render () { + if render() { div { // Set the ID for the label id, diff --git a/primitives/src/select/components/list.rs b/primitives/src/select/components/list.rs index f75b405a..e8d416f5 100644 --- a/primitives/src/select/components/list.rs +++ b/primitives/src/select/components/list.rs @@ -163,10 +163,12 @@ pub fn SelectList(props: SelectListProps) -> Element { use_effect(move || { if render() { - if (ctx.initial_focus_last)().unwrap_or_default() { - ctx.focus_last(); - } else { - ctx.focus_first(); + if let Some(last) = (ctx.initial_focus_last)() { + if last { + ctx.focus_last(); + } else { + ctx.focus_first(); + } } } else { ctx.initial_focus_last.set(None); diff --git a/primitives/src/select/components/option.rs b/primitives/src/select/components/option.rs index 39f78ebb..d991111d 100644 --- a/primitives/src/select/components/option.rs +++ b/primitives/src/select/components/option.rs @@ -194,7 +194,12 @@ pub fn SelectOption(props: SelectOptionProps) "data-disabled": disabled, onpointerdown: move |event| { - if !disabled && event.trigger_button() == Some(MouseButton::Primary) { + if event.trigger_button() == Some(MouseButton::Primary) { + if disabled { + event.prevent_default(); + event.stop_propagation(); + return; + } ctx.set_value.call(Some(RcPartialEqValue::new(props.value.cloned()))); ctx.open.set(false); } diff --git a/primitives/src/select/components/trigger.rs b/primitives/src/select/components/trigger.rs index 82ff4474..31976740 100644 --- a/primitives/src/select/components/trigger.rs +++ b/primitives/src/select/components/trigger.rs @@ -69,11 +69,13 @@ pub fn SelectTrigger(props: SelectTriggerProps) -> Element { let mut ctx = use_context::(); let mut open = ctx.open; + let focus_id = use_memo(move || ctx.current_focus_id()); + rsx! { button { // Standard HTML attributes disabled: (ctx.disabled)(), - type: "button", + r#type: "button", onclick: move |_| { open.toggle(); @@ -97,9 +99,11 @@ pub fn SelectTrigger(props: SelectTriggerProps) -> Element { }, // ARIA attributes + role: "combobox", aria_haspopup: "listbox", aria_expanded: open(), aria_controls: ctx.list_id, + aria_activedescendant: focus_id, // Pass through other attributes ..props.attributes, diff --git a/primitives/src/select/components/value.rs b/primitives/src/select/components/value.rs index 4f9fc4c3..982270cd 100644 --- a/primitives/src/select/components/value.rs +++ b/primitives/src/select/components/value.rs @@ -80,10 +80,6 @@ pub fn SelectValue(props: SelectValueProps) -> Element { rsx! { // Add placeholder option if needed - span { - "data-placeholder": ctx.value.read().is_none(), - ..props.attributes, - {display_value} - } + span { "data-placeholder": ctx.value.read().is_none(), ..props.attributes, {display_value} } } } diff --git a/primitives/src/select/context.rs b/primitives/src/select/context.rs index ac6999a5..be50929a 100644 --- a/primitives/src/select/context.rs +++ b/primitives/src/select/context.rs @@ -69,11 +69,11 @@ pub(super) struct SelectContext { /// Timeout before clearing typeahead buffer pub typeahead_timeout: ReadSignal, /// A list of options with their states - pub options: Signal>, + pub(crate) options: Signal>, /// If focus should loop around pub roving_loop: ReadSignal, /// The currently selected option tab_index - pub current_focus: Signal>, + pub(crate) current_focus: Signal>, /// The initial element to focus once the list is rendered
/// true: last element
/// false: first element @@ -94,6 +94,11 @@ impl SelectContext { (self.current_focus)() } + pub(crate) fn current_focus_id(&self) -> Option { + let focus = (self.current_focus)()?; + self.options.read().get(&focus).map(|s| s.id.clone()) + } + pub(crate) fn blur(&mut self) { self.current_focus.write().take(); }