From 798ec9d02fad0645a85f8a2316f3e80c9a5d957d Mon Sep 17 00:00:00 2001 From: knox Date: Wed, 17 Dec 2025 18:10:27 +0100 Subject: [PATCH 1/4] 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/4] 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/4] 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(); } From d284d8939559b2891422663b33ee5308d1832bc7 Mon Sep 17 00:00:00 2001 From: knox Date: Wed, 7 Jan 2026 01:49:51 +0100 Subject: [PATCH 4/4] use indexmap within FocusState for tab_index and disabled state --- Cargo.lock | 13 +- preview/src/components/select/component.rs | 2 +- .../select/variants/disabled/mod.rs | 4 +- .../components/select/variants/main/mod.rs | 4 +- primitives/Cargo.toml | 1 + primitives/src/context_menu.rs | 2 +- primitives/src/date_picker.rs | 2 +- primitives/src/dropdown_menu.rs | 2 +- primitives/src/focus.rs | 264 +++++++++++++----- primitives/src/menubar.rs | 21 +- primitives/src/navbar.rs | 24 +- primitives/src/radio_group.rs | 3 +- primitives/src/select/components/group.rs | 8 +- primitives/src/select/components/list.rs | 18 +- primitives/src/select/components/option.rs | 52 ++-- primitives/src/select/components/select.rs | 23 +- primitives/src/select/components/trigger.rs | 6 +- primitives/src/select/components/value.rs | 4 +- primitives/src/select/context.rs | 154 ++-------- primitives/src/select/mod.rs | 4 +- primitives/src/select/text_search.rs | 96 ++++--- primitives/src/tabs.rs | 2 +- primitives/src/toggle_group.rs | 4 +- 23 files changed, 370 insertions(+), 343 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 893db865..aa78c561 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2244,6 +2244,7 @@ version = "0.0.1" dependencies = [ "dioxus", "dioxus-sdk-time", + "indexmap", "lazy-js-bundle 0.6.2", "num-integer", "time", @@ -3680,9 +3681,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", @@ -4172,12 +4173,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.11.4" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.16.1", ] [[package]] @@ -4548,7 +4549,7 @@ version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96051b46fc183dc9cd4a223960ef37b9af631b55191852a8274bfef064cda20f" dependencies = [ - "hashbrown 0.16.0", + "hashbrown 0.16.1", ] [[package]] diff --git a/preview/src/components/select/component.rs b/preview/src/components/select/component.rs index 88e70f02..a3fff472 100644 --- a/preview/src/components/select/component.rs +++ b/preview/src/components/select/component.rs @@ -92,7 +92,7 @@ pub fn SelectOption(props: SelectOptionProps) text_value: props.text_value, disabled: props.disabled, id: props.id, - index: props.index, + tab_index: props.tab_index, aria_label: props.aria_label, aria_roledescription: props.aria_roledescription, attributes: props.attributes, diff --git a/preview/src/components/select/variants/disabled/mod.rs b/preview/src/components/select/variants/disabled/mod.rs index 9739f32d..01c2e210 100644 --- a/preview/src/components/select/variants/disabled/mod.rs +++ b/preview/src/components/select/variants/disabled/mod.rs @@ -36,7 +36,7 @@ pub fn Demo() -> Element { let fruits = Fruit::iter().enumerate().map(|(i, f)| { rsx! { SelectOption::> { - index: i, + tab_index: i, value: f, text_value: "{f}", disabled: f.disabled(), @@ -57,7 +57,7 @@ pub fn Demo() -> Element { SelectGroup { SelectGroupLabel { "Other" } SelectOption::> { - index: Fruit::COUNT, + tab_index: Fruit::COUNT, value: None, text_value: "Other", disabled: true, diff --git a/preview/src/components/select/variants/main/mod.rs b/preview/src/components/select/variants/main/mod.rs index 7f5386f9..a15b63f2 100644 --- a/preview/src/components/select/variants/main/mod.rs +++ b/preview/src/components/select/variants/main/mod.rs @@ -27,7 +27,7 @@ impl Fruit { pub fn Demo() -> Element { let fruits = Fruit::iter().enumerate().map(|(i, f)| { rsx! { - SelectOption::> { index: i, value: f, text_value: "{f}", + SelectOption::> { tab_index: i, value: f, text_value: "{f}", {format!("{} {f}", f.emoji())} SelectItemIndicator {} } @@ -46,7 +46,7 @@ pub fn Demo() -> Element { SelectGroup { SelectGroupLabel { "Other" } SelectOption::> { - index: Fruit::COUNT, + tab_index: Fruit::COUNT, value: None, text_value: "Other", "Other" diff --git a/primitives/Cargo.toml b/primitives/Cargo.toml index 182ee314..26ce1512 100644 --- a/primitives/Cargo.toml +++ b/primitives/Cargo.toml @@ -17,6 +17,7 @@ dioxus-sdk-time = "0.7.0" time = { version = "0.3.41", features = ["std", "macros", "parsing"] } num-integer = "0.1.46" tracing.workspace = true +indexmap = "2.12.1" [build-dependencies] lazy-js-bundle = "0.6.2" diff --git a/primitives/src/context_menu.rs b/primitives/src/context_menu.rs index c519d14f..4c534967 100644 --- a/primitives/src/context_menu.rs +++ b/primitives/src/context_menu.rs @@ -463,7 +463,7 @@ pub fn ContextMenuItem(props: ContextMenuItemProps) -> Element { let focused = move || ctx.focus.is_focused(props.index.cloned()); // Handle settings focus - let onmounted = use_focus_controlled_item(props.index); + let onmounted = use_focus_controlled_item(props.index.cloned(), props.index); // Determine if this item is currently focused let tab_index = use_memo(move || if focused() { "0" } else { "-1" }); diff --git a/primitives/src/date_picker.rs b/primitives/src/date_picker.rs index 83090e0c..8188167f 100644 --- a/primitives/src/date_picker.rs +++ b/primitives/src/date_picker.rs @@ -550,7 +550,7 @@ fn DateSegment( } }; - let onmounted = use_focus_controlled_item(props.index); + let onmounted = use_focus_controlled_item(props.index.cloned(), props.index); let span_id = use_unique_id(); let id = use_memo(move || format!("span-{span_id}")); diff --git a/primitives/src/dropdown_menu.rs b/primitives/src/dropdown_menu.rs index fd7f740f..4fe95580 100644 --- a/primitives/src/dropdown_menu.rs +++ b/primitives/src/dropdown_menu.rs @@ -416,7 +416,7 @@ pub fn DropdownMenuItem( let disabled = move || (ctx.disabled)() || (props.disabled)(); let focused = move || ctx.focus.is_focused((props.index)()); - let onmounted = use_focus_controlled_item(props.index); + let onmounted = use_focus_controlled_item(props.index.cloned(), props.index); rsx! { div { diff --git a/primitives/src/focus.rs b/primitives/src/focus.rs index 1d574f9a..7410cfd6 100644 --- a/primitives/src/focus.rs +++ b/primitives/src/focus.rs @@ -1,67 +1,72 @@ -use std::rc::Rc; - -use dioxus::prelude::*; - use crate::use_effect_cleanup; +use dioxus::prelude::*; +use indexmap::IndexMap; +use std::ops::ControlFlow; +use std::rc::Rc; +use std::sync::atomic::{AtomicUsize, Ordering}; pub(crate) fn use_focus_provider(roving_loop: ReadSignal) -> FocusState { use_context_provider(|| { - let item_count = Signal::new(0); let recent_focus = Signal::new(None); let current_focus = Signal::new(None); + let items = Signal::new(IndexMap::new()); FocusState { - item_count, recent_focus, current_focus, roving_loop, + items, } }) } +/// If you don't already have a unique id, use this hook to generate one. +pub(crate) fn use_focus_unique_id() -> usize { + static NEXT_ID: AtomicUsize = AtomicUsize::new(0); + + #[allow(unused_mut)] + let mut initial_value = use_hook(|| NEXT_ID.fetch_add(1, Ordering::Relaxed)); + + fullstack! { + let server_id = dioxus::prelude::use_server_cached(move || { + initial_value + }); + initial_value = server_id; + } + initial_value +} + pub(crate) fn use_focus_entry( ctx: FocusState, - index: impl Readable + Copy + 'static, + id: usize, + tab_index: impl Readable + Copy + 'static, ) { let disabled = use_signal(|| false); - use_focus_entry_disabled(ctx, index, disabled); + use_focus_entry_disabled(ctx, id, tab_index, disabled); } pub(crate) fn use_focus_entry_disabled( mut ctx: FocusState, - index: impl Readable + Copy + 'static, - disabled: impl Readable + 'static, + id: usize, + tab_index: impl Readable + Copy + 'static, + disabled: impl Readable + Copy + 'static, ) { - let mut item = use_hook(|| CopyValue::new(false)); use_effect(move || { - if disabled.cloned() { - if item.cloned() { - ctx.remove_item(index.cloned()); - item.set(false); - } - } else { - ctx.add_item(); - item.set(true); - } + ctx.add_update_item(id, tab_index, disabled); }); use_effect_cleanup(move || { - if item.cloned() { - ctx.remove_item(index.cloned()); - } - }); + ctx.remove_item(id); + }) } -pub(crate) fn use_focus_control( - ctx: FocusState, - index: impl Readable + Copy + 'static, -) -> impl FnMut(MountedEvent) { +pub(crate) fn use_focus_control(ctx: FocusState, id: usize) -> impl FnMut(MountedEvent) { let disabled = use_signal(|| false); - use_focus_control_disabled(ctx, index, disabled) + use_focus_control_disabled(ctx, id, disabled) } pub(crate) fn use_focus_control_disabled( ctx: FocusState, - index: impl Readable + Copy + 'static, + id: usize, disabled: impl Readable + 'static, ) -> impl FnMut(MountedEvent) { let mut controlled_ref: Signal>> = use_signal(|| None); @@ -69,34 +74,46 @@ pub(crate) fn use_focus_control_disabled( if disabled.cloned() { return; } - ctx.control_mount_focus(index.cloned(), controlled_ref); + ctx.control_mount_focus(id, controlled_ref); }); move |data: Event| controlled_ref.set(Some(data.data())) } pub(crate) fn use_focus_controlled_item( + id: usize, index: impl Readable + Copy + 'static, ) -> impl FnMut(MountedEvent) { let disabled = use_signal(|| false); - use_focus_controlled_item_disabled(index, disabled) + use_focus_controlled_item_disabled(id, index, disabled) } pub(crate) fn use_focus_controlled_item_disabled( + id: usize, index: impl Readable + Copy + 'static, disabled: impl Readable + Copy + 'static, ) -> impl FnMut(MountedEvent) { let ctx: FocusState = use_context(); - use_focus_entry_disabled(ctx, index, disabled); - use_focus_control_disabled(ctx, index, disabled) + use_focus_entry_disabled(ctx, id, index, disabled); + use_focus_control_disabled(ctx, id, disabled) +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct FocusStateElem { + pub(crate) tab_index: usize, + pub(crate) disabled: bool, } #[derive(Clone, Copy)] pub(crate) struct FocusState { pub(crate) roving_loop: ReadSignal, - pub(crate) item_count: Signal, + /// Recent focus is only None if this State is created. + /// It will normally hold the last focused item, even when no item is currently focused. + /// Similar to current_focus this holds the key into the map. pub(crate) recent_focus: Signal>, + /// Key into the map that is currently focused. pub(crate) current_focus: Signal>, + pub(crate) items: Signal>, } impl FocusState { @@ -107,42 +124,113 @@ impl FocusState { self.current_focus.set(index); } - pub(crate) fn focus_next(&mut self) { - let current_focus = self.recent_focus(); - let mut new_focus = current_focus - .map(|x| x.saturating_add(1)) - .unwrap_or_default(); - - let item_count = (self.item_count)(); - if new_focus >= item_count { - match (self.roving_loop)() { - true => new_focus = 0, - false => new_focus = item_count.saturating_sub(1), + fn current_index(&self) -> Option { + let current_focus = self.current_focus()?; + + self.items.read().get_index_of(¤t_focus) + } + + fn focus_enabled(&mut self, index: usize, reverse: bool) { + let Self { + items, + roving_loop, + recent_focus, + current_focus, + } = self; + let items = items.read(); + + let (range1, range2) = if !reverse { + (index + 1..items.len(), 0..index) + } else { + (0..index, index + 1..items.len()) + }; + + let iter = items.get_range(range1).into_iter().flat_map(|x| x.iter()); + + let iter2 = roving_loop() + .then(|| items.get_range(range2).into_iter().flat_map(|x| x.iter())) + .into_iter() + .flatten(); + + let it = |(&key, value): (&usize, &FocusStateElem)| { + if !value.disabled { + recent_focus.set(Some(key)); + current_focus.set(Some(key)); + return ControlFlow::Break(()); } + ControlFlow::Continue(()) + }; + + if !reverse { + let _ = iter.chain(iter2).try_for_each(it); + } else { + let _ = iter2.chain(iter).rev().try_for_each(it); } + } - self.set_focus(Some(new_focus)); + pub(crate) fn focus_next(&mut self) { + let index = match self.current_index() { + Some(x) => x, + None => return self.focus_first(), + }; + + self.focus_enabled(index, false); } pub(crate) fn focus_prev(&mut self) { - let current_focus = self.recent_focus(); - let mut new_focus = current_focus - .map(|x| x.saturating_sub(1)) - .unwrap_or_default(); - if current_focus.unwrap_or_default() == 0 && (self.roving_loop)() { - new_focus = (self.item_count)().saturating_sub(1); - } + let index = match self.current_index() { + Some(x) => x, + None => return self.focus_last(), + }; - self.set_focus(Some(new_focus)); + self.focus_enabled(index, true); } pub(crate) fn focus_first(&mut self) { - self.set_focus(Some(0)); + let key = { + self.items + .read() + .iter() + .filter(|(_, value)| !value.disabled) + .map(|(&key, _)| key) + .next() + }; + if let Some(key) = key { + self.set_focus(Some(key)); + } } pub(crate) fn focus_last(&mut self) { - let last_index = self.item_count.cloned() - 1; - self.set_focus(Some(last_index)); + let key = { + self.items + .read() + .iter() + .rev() + .filter(|(_, value)| !value.disabled) + .map(|(&key, _)| key) + .next() + }; + if let Some(key) = key { + self.set_focus(Some(key)); + } + } + + // pub(crate) fn focus_recent_or_first(&mut self) { + // if let Some(id) = self.recent_focus() { + // self.current_focus.set(Some(id)); + // } else { + // self.focus_first(); + // } + // } + + pub(crate) fn focus_recent_or_first(&mut self) { + if let Some(id) = self.recent_focus() { + if self.items.peek().contains_key(&id) { + self.current_focus.set(Some(id)); + return; + } + } + self.focus_first(); } pub(crate) fn blur(&mut self) { @@ -165,31 +253,67 @@ impl FocusState { (self.recent_focus)() } - pub(crate) fn recent_focus_or_default(&self) -> usize { - (self.recent_focus)().unwrap_or_default() - } + /// `id`: The unique ID of the item to be added. I suggest using props.id here. + /// This code also runs when the tab_index or disabled changes + pub(crate) fn add_update_item( + &mut self, + id: usize, + tab_index: impl Readable + Copy + 'static, + disabled: impl Readable + Copy + 'static, + ) { + let tab_index = tab_index.cloned(); + let disabled = disabled.cloned(); + + { + // update item if it already exists + let mut items = self.items.write(); + let item = items.get_mut(&id); + if let Some(item) = item { + item.disabled = disabled; + let changed = item.tab_index != tab_index; + item.tab_index = tab_index; - pub(crate) fn add_item(&mut self) { - self.item_count += 1; + // if the tab_index didn't change, we don't need to refresh its order. + if !changed { + return; + } + } + } + + let index = self + .items + .peek() + .partition_point(|_, value| value.tab_index <= tab_index); + + self.items.write().insert_before( + index, + id, + FocusStateElem { + tab_index, + disabled, + }, + ); } pub(crate) fn item_count(&self) -> usize { - self.item_count.cloned() + self.items.read().len() } - pub(crate) fn remove_item(&mut self, index: usize) { - self.item_count -= 1; - if (self.current_focus)() == Some(index) { - self.set_focus(None); + pub(crate) fn remove_item(&mut self, id: usize) { + let elem = self.items.write().shift_remove_full(&id); + if let Some((_, key, _)) = elem { + if (self.current_focus)() == Some(key) { + self.set_focus(None); + } } } pub(crate) fn control_mount_focus( &self, - index: usize, + id: usize, controlled_ref: Signal>>, ) { - let is_focused = self.is_focused(index); + let is_focused = self.is_focused(id); if is_focused { if let Some(md) = controlled_ref() { spawn(async move { diff --git a/primitives/src/menubar.rs b/primitives/src/menubar.rs index e9af969c..c051df4b 100644 --- a/primitives/src/menubar.rs +++ b/primitives/src/menubar.rs @@ -132,7 +132,7 @@ pub fn Menubar(props: MenubarProps) -> Element { tabindex: (!ctx.focus.any_focused()).then_some("0"), // If the menu receives focus, focus the most recently focused menu item onfocus: move |_| { - ctx.focus.set_focus(Some(ctx.focus.recent_focus_or_default())); + ctx.focus.focus_recent_or_first(); }, ..props.attributes, @@ -148,6 +148,10 @@ struct MenubarMenuContext { focus: FocusState, is_open: Memo, disabled: ReadSignal, + /// The initial element to focus once the list is opened
+ /// true: last element
+ /// false: first element + pub initial_focus_last: Signal>, } impl MenubarMenuContext { @@ -253,20 +257,28 @@ pub fn MenubarMenu(props: MenubarMenuProps) -> Element { let mut ctx: MenubarContext = use_context(); let is_open = use_memo(move || (ctx.open_menu)() == Some(props.index.cloned())); let focus = use_focus_provider(ctx.focus.roving_loop); + let initial_focus_last = use_signal(|| None); let mut menu_ctx = use_context_provider(|| MenubarMenuContext { index: props.index, focus, is_open, disabled: props.disabled, + initial_focus_last, }); use_effect(move || { if !is_open() { menu_ctx.focus.blur(); + } else if let Some(last) = (menu_ctx.initial_focus_last)() { + if last { + menu_ctx.focus.focus_last(); + } else { + menu_ctx.focus.focus_first(); + } } }); - use_focus_entry(ctx.focus, menu_ctx.index); + use_focus_entry(ctx.focus, menu_ctx.index.cloned(), menu_ctx.index); let disabled = move || (ctx.disabled)() || (props.disabled)(); @@ -286,6 +298,7 @@ pub fn MenubarMenu(props: MenubarMenuProps) -> Element { Key::ArrowRight => ctx.focus.focus_next(), Key::ArrowDown if !disabled() => { if !is_open() { + menu_ctx.initial_focus_last.set(Some(false)); ctx.set_open_menu.call(Some(props.index.cloned())); } menu_ctx.focus_next(); @@ -385,7 +398,7 @@ pub struct MenubarTriggerProps { pub fn MenubarTrigger(props: MenubarTriggerProps) -> Element { let mut ctx: MenubarContext = use_context(); let menu_ctx: MenubarMenuContext = use_context(); - let onmounted = use_focus_control(ctx.focus, menu_ctx.index); + let onmounted = use_focus_control(ctx.focus, menu_ctx.index.cloned()); let disabled = move || (ctx.disabled)() || (menu_ctx.disabled)(); let is_open = menu_ctx.is_open; let index = menu_ctx.index; @@ -627,7 +640,7 @@ pub fn MenubarItem(props: MenubarItemProps) -> Element { let disabled = move || (ctx.disabled)() || (props.disabled)(); let focused = move || menu_ctx.focus.is_focused(props.index.cloned()) && (menu_ctx.is_open)(); - let onmounted = use_focus_controlled_item(props.index); + let onmounted = use_focus_controlled_item(props.index.cloned(), props.index); rsx! { div { diff --git a/primitives/src/navbar.rs b/primitives/src/navbar.rs index 2c88ce52..e07944e6 100644 --- a/primitives/src/navbar.rs +++ b/primitives/src/navbar.rs @@ -141,7 +141,7 @@ pub fn Navbar(props: NavbarProps) -> Element { tabindex: (!ctx.focus.any_focused()).then_some("0"), // If the menu receives focus, focus the most recently focused menu item onfocus: move |_| { - ctx.focus.set_focus(Some(ctx.focus.recent_focus_or_default())); + ctx.focus.focus_recent_or_first(); }, onkeydown: move |event: Event| { match event.key() { @@ -169,6 +169,10 @@ struct NavbarNavContext { focus: FocusState, is_open: Memo, disabled: ReadSignal, + /// The initial element to focus once the list is opened
+ /// true: last element
+ /// false: first element + pub initial_focus_last: Signal>, } impl NavbarNavContext { @@ -274,20 +278,28 @@ pub fn NavbarNav(props: NavbarNavProps) -> Element { let mut ctx: NavbarContext = use_context(); let is_open = use_memo(move || (ctx.open_nav)() == Some(props.index.cloned())); let focus = use_focus_provider(ctx.focus.roving_loop); + let initial_focus_last = use_signal(|| None); let mut nav_ctx = use_context_provider(|| NavbarNavContext { index: props.index, focus, is_open, disabled: props.disabled, + initial_focus_last, }); use_effect(move || { if !is_open() { nav_ctx.focus.blur(); + } else if let Some(last) = (nav_ctx.initial_focus_last)() { + if last { + nav_ctx.focus.focus_last(); + } else { + nav_ctx.focus.focus_first(); + } } }); - use_focus_entry(ctx.focus, nav_ctx.index); + use_focus_entry(ctx.focus, nav_ctx.index.cloned(), nav_ctx.index); let disabled = move || (ctx.disabled)() || (props.disabled)(); @@ -319,6 +331,7 @@ pub fn NavbarNav(props: NavbarNavProps) -> Element { } Key::ArrowDown if !disabled() => { if !is_open() { + nav_ctx.initial_focus_last.set(Some(false)); ctx.set_open_nav.call(Some(props.index.cloned())); } nav_ctx.focus_next(); @@ -418,7 +431,7 @@ pub struct NavbarTriggerProps { pub fn NavbarTrigger(props: NavbarTriggerProps) -> Element { let mut ctx: NavbarContext = use_context(); let nav_ctx: NavbarNavContext = use_context(); - let onmounted = use_focus_control(ctx.focus, nav_ctx.index); + let onmounted = use_focus_control(ctx.focus, nav_ctx.index.cloned()); let is_focused = move || { ctx.focus.current_focus() == Some(nav_ctx.index.cloned()) && !nav_ctx.focus.any_focused() }; @@ -710,7 +723,7 @@ pub fn NavbarItem(mut props: NavbarItemProps) -> Element { ) }; - let mut onmounted = use_focus_controlled_item(props.index); + let mut onmounted = use_focus_controlled_item(props.index.cloned(), props.index); props.attributes.push(onkeydown({ let value = props.value.clone(); @@ -746,7 +759,8 @@ pub fn NavbarItem(mut props: NavbarItemProps) -> Element { })); let tabindex = if focused() - || (nav_ctx.is_none() && ctx.focus.recent_focus_or_default() == props.index.cloned()) + || (nav_ctx.is_none() + && ctx.focus.recent_focus().unwrap_or_default() == props.index.cloned()) { "0" } else { diff --git a/primitives/src/radio_group.rs b/primitives/src/radio_group.rs index 971c9d58..db4e2e40 100644 --- a/primitives/src/radio_group.rs +++ b/primitives/src/radio_group.rs @@ -286,7 +286,8 @@ pub fn RadioItem(props: RadioItemProps) -> Element { "-1" }); - let onmounted = use_focus_controlled_item_disabled(props.index, props.disabled); + let onmounted = + use_focus_controlled_item_disabled(props.index.cloned(), props.index, props.disabled); rsx! { button { diff --git a/primitives/src/select/components/group.rs b/primitives/src/select/components/group.rs index 53aaedb2..8a7789b5 100644 --- a/primitives/src/select/components/group.rs +++ b/primitives/src/select/components/group.rs @@ -53,13 +53,13 @@ pub struct SelectGroupProps { /// SelectGroup { /// SelectGroupLabel { "Fruits" } /// SelectOption:: { -/// index: 0usize, +/// tab_index: 0usize, /// value: "apple", /// "Apple" /// SelectItemIndicator { "✔️" } /// } /// SelectOption:: { -/// index: 1usize, +/// tab_index: 1usize, /// value: "banana", /// "Banana" /// SelectItemIndicator { "✔️" } @@ -142,13 +142,13 @@ pub struct SelectGroupLabelProps { /// SelectGroup { /// SelectGroupLabel { "Fruits" } /// SelectOption:: { -/// index: 0usize, +/// tab_index: 0usize, /// value: "apple", /// "Apple" /// SelectItemIndicator { "✔️" } /// } /// SelectOption:: { -/// index: 1usize, +/// tab_index: 1usize, /// value: "banana", /// "Banana" /// SelectItemIndicator { "✔️" } diff --git a/primitives/src/select/components/list.rs b/primitives/src/select/components/list.rs index e8d416f5..3aae24d1 100644 --- a/primitives/src/select/components/list.rs +++ b/primitives/src/select/components/list.rs @@ -52,13 +52,13 @@ pub struct SelectListProps { /// SelectGroup { /// SelectGroupLabel { "Fruits" } /// SelectOption:: { -/// index: 0usize, +/// tab_index: 0usize, /// value: "apple", /// "Apple" /// SelectItemIndicator { "✔️" } /// } /// SelectOption:: { -/// index: 1usize, +/// tab_index: 1usize, /// value: "banana", /// "Banana" /// SelectItemIndicator { "✔️" } @@ -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.any_focused(); + let focused = move || open() && !ctx.focus_state.any_focused(); use_effect(move || { let Some(listbox_ref) = listbox_ref() else { @@ -125,19 +125,19 @@ pub fn SelectList(props: SelectListProps) -> Element { } Key::ArrowUp => { arrow_key_navigation(event); - ctx.focus_prev(); + ctx.focus_state.focus_prev(); } Key::End => { arrow_key_navigation(event); - ctx.focus_last(); + ctx.focus_state.focus_last(); } Key::ArrowDown => { arrow_key_navigation(event); - ctx.focus_next(); + ctx.focus_state.focus_next(); } Key::Home => { arrow_key_navigation(event); - ctx.focus_first(); + ctx.focus_state.focus_first(); } Key::Enter => { ctx.select_current_item(); @@ -165,9 +165,9 @@ pub fn SelectList(props: SelectListProps) -> Element { if render() { if let Some(last) = (ctx.initial_focus_last)() { if last { - ctx.focus_last(); + ctx.focus_state.focus_last(); } else { - ctx.focus_first(); + ctx.focus_state.focus_first(); } } } else { diff --git a/primitives/src/select/components/option.rs b/primitives/src/select/components/option.rs index d991111d..90e57863 100644 --- a/primitives/src/select/components/option.rs +++ b/primitives/src/select/components/option.rs @@ -1,14 +1,13 @@ //! SelectOption and SelectItemIndicator component implementations. +use super::super::context::{OptionState, SelectContext, SelectOptionContext}; +use crate::focus::{use_focus_controlled_item_disabled, use_focus_unique_id}; use crate::{ 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}; /// The props for the [`SelectOption`] component #[derive(Props, Clone, PartialEq)] @@ -24,12 +23,12 @@ pub struct SelectOptionProps { #[props(default)] pub disabled: ReadSignal, - /// Optional ID for the option + /// Optional HTML ID for the option. #[props(default)] pub id: ReadSignal>, - /// 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, + /// The tab_index of the option in the list. This is used to define the focus order for keyboard navigation. + pub tab_index: ReadSignal, /// Optional label for the option (for accessibility) #[props(default)] @@ -82,13 +81,13 @@ pub struct SelectOptionProps { /// SelectGroup { /// SelectGroupLabel { "Fruits" } /// SelectOption:: { -/// index: 0usize, +/// tab_index: 0usize, /// value: "apple", /// "Apple" /// SelectItemIndicator { "✔️" } /// } /// SelectOption:: { -/// index: 1usize, +/// tab_index: 1usize, /// value: "banana", /// "Banana" /// SelectItemIndicator { "✔️" } @@ -107,7 +106,7 @@ pub fn SelectOption(props: SelectOptionProps) // Use use_id_or to handle the ID let id = use_id_or(option_id, props.id); - let index = props.index; + let tab_index = props.tab_index; let value = props.value; let text_value = use_memo(move || match (props.text_value)() { Some(text) => text, @@ -127,12 +126,13 @@ pub fn SelectOption(props: SelectOptionProps) } }); + let focus_id = use_focus_unique_id(); + // 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(), @@ -140,32 +140,16 @@ pub fn SelectOption(props: SelectOptionProps) }; // Add the option to the context's options - ctx.options - .write() - .insert(option_state.tab_index, option_state); + ctx.options.write().insert(focus_id, option_state); }); use_effect_cleanup(move || { - ctx.options.write().remove(&index()); + ctx.options.write().remove(&focus_id); }); - // 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 onmounted = use_focus_controlled_item_disabled(focus_id, tab_index, props.disabled); - let focused = move || ctx.is_focused(index()); + let focused = move || ctx.focus_state.is_focused(focus_id); let selected = use_memo(move || { ctx.value.read().as_ref().and_then(|v| v.as_ref::()) == Some(&props.value.read()) }); @@ -182,7 +166,7 @@ pub fn SelectOption(props: SelectOptionProps) role: "option", id, tabindex: if focused() { "0" } else { "-1" }, - onmounted: move |data: Event| controlled_ref.set(Some(data.data())), + onmounted, // ARIA attributes aria_selected: selected(), @@ -206,7 +190,7 @@ pub fn SelectOption(props: SelectOptionProps) }, onblur: move |_| { if focused() { - ctx.blur(); + ctx.focus_state.blur(); ctx.open.set(false); } }, @@ -255,13 +239,13 @@ pub struct SelectItemIndicatorProps { /// SelectGroup { /// SelectGroupLabel { "Fruits" } /// SelectOption:: { -/// index: 0usize, +/// tab_index: 0usize, /// value: "apple", /// "Apple" /// SelectItemIndicator { "✔️" } /// } /// SelectOption:: { -/// index: 1usize, +/// tab_index: 1usize, /// value: "banana", /// "Banana" /// SelectItemIndicator { "✔️" } diff --git a/primitives/src/select/components/select.rs b/primitives/src/select/components/select.rs index 602809b1..93954f4f 100644 --- a/primitives/src/select/components/select.rs +++ b/primitives/src/select/components/select.rs @@ -1,15 +1,14 @@ //! Main Select component implementation. -use core::panic; -use std::collections::BTreeMap; +use std::collections::HashMap; use std::time::Duration; +use super::super::context::SelectContext; +use crate::focus::use_focus_provider; use crate::{select::context::RcPartialEqValue, use_controlled, use_effect}; use dioxus::prelude::*; use dioxus_core::Task; -use super::super::context::SelectContext; - /// Props for the main Select component #[derive(Props, Clone, PartialEq)] pub struct SelectProps { @@ -80,13 +79,13 @@ pub struct SelectProps { /// SelectGroup { /// SelectGroupLabel { "Fruits" } /// SelectOption:: { -/// index: 0usize, +/// tab_index: 0usize, /// value: "apple", /// "Apple" /// SelectItemIndicator { "✔️" } /// } /// SelectOption:: { -/// index: 1usize, +/// tab_index: 1usize, /// value: "banana", /// "Banana" /// SelectItemIndicator { "✔️" } @@ -109,7 +108,7 @@ pub fn Select(props: SelectProps) -> Element let open = use_signal(|| false); let mut typeahead_buffer = use_signal(String::new); - let options = use_signal(BTreeMap::new); + let options = use_signal(HashMap::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,6 +129,9 @@ pub fn Select(props: SelectProps) -> Element } }); + let focus_state = use_focus_provider(props.roving_loop); + let initial_focus_last = use_signal(|| None); + // Clear the typeahead buffer when the select is closed use_effect(move || { if !open() { @@ -141,24 +143,21 @@ pub fn Select(props: SelectProps) -> Element typeahead_buffer.take(); } }); - let initial_focus_last = use_signal(|| None); - let current_focus = use_signal(|| None); use_context_provider(|| SelectContext { typeahead_buffer, open, value, set_value, - options, - roving_loop: props.roving_loop, adaptive_keyboard, list_id, disabled: props.disabled, placeholder: props.placeholder, typeahead_clear_task, typeahead_timeout: props.typeahead_timeout, + focus_state, + options, initial_focus_last, - current_focus, }); rsx! { diff --git a/primitives/src/select/components/trigger.rs b/primitives/src/select/components/trigger.rs index 31976740..4de48625 100644 --- a/primitives/src/select/components/trigger.rs +++ b/primitives/src/select/components/trigger.rs @@ -42,13 +42,13 @@ pub struct SelectTriggerProps { /// SelectGroup { /// SelectGroupLabel { "Fruits" } /// SelectOption:: { -/// index: 0usize, +/// tab_index: 0usize, /// value: "apple", /// "Apple" /// SelectItemIndicator { "✔️" } /// } /// SelectOption:: { -/// index: 1usize, +/// tab_index: 1usize, /// value: "banana", /// "Banana" /// SelectItemIndicator { "✔️" } @@ -69,7 +69,7 @@ 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()); + let focus_id = use_memo(move || ctx.current_item_id()); rsx! { button { diff --git a/primitives/src/select/components/value.rs b/primitives/src/select/components/value.rs index 982270cd..e290f278 100644 --- a/primitives/src/select/components/value.rs +++ b/primitives/src/select/components/value.rs @@ -39,13 +39,13 @@ pub struct SelectValueProps { /// SelectGroup { /// SelectGroupLabel { "Fruits" } /// SelectOption:: { -/// index: 0usize, +/// tab_index: 0usize, /// value: "apple", /// "Apple" /// SelectItemIndicator { "✔️" } /// } /// SelectOption:: { -/// index: 1usize, +/// tab_index: 1usize, /// value: "banana", /// "Banana" /// SelectItemIndicator { "✔️" } diff --git a/primitives/src/select/context.rs b/primitives/src/select/context.rs index be50929a..a40b5f88 100644 --- a/primitives/src/select/context.rs +++ b/primitives/src/select/context.rs @@ -5,7 +5,8 @@ use dioxus_core::Task; use dioxus_sdk_time::sleep; use super::text_search::AdaptiveKeyboard; -use std::collections::BTreeMap; +use crate::focus::FocusState; +use std::collections::HashMap; use std::{any::Any, rc::Rc, time::Duration}; trait DynPartialEq: Any { @@ -68,12 +69,10 @@ pub(super) struct SelectContext { pub typeahead_clear_task: Signal>, /// Timeout before clearing typeahead buffer pub typeahead_timeout: ReadSignal, + /// The focus state for the select + pub focus_state: FocusState, /// A list of options with their states - pub(crate) options: Signal>, - /// If focus should loop around - pub roving_loop: ReadSignal, - /// The currently selected option tab_index - pub(crate) current_focus: Signal>, + pub(crate) options: Signal>, /// The initial element to focus once the list is rendered
/// true: last element
/// false: first element @@ -81,138 +80,21 @@ 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 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(); - } - - /// custom implementation for `FocusState::focus_next` - pub(crate) fn focus_next(&mut self) { - // 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(); - - // 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; - } - } - - // stop if we dont allow rollover - if !(self.roving_loop)() { - return; - } - - // 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; - } - - // 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) { - // focus last if current is none - let current_focus = match self.current_focus() { - Some(k) => k, - None => return self.focus_last(), - }; - - let options = self.options.read(); - - // 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; - } - } - - // stop if we dont allow rollover - if !(self.roving_loop)() { - return; - } - - // 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; - } - - // focus if not disabled - if !state.disabled { - self.current_focus.set(Some(*index)); - return; - } - } - } - - /// custom implementation for `FocusState::focus_first` - pub(crate) fn focus_first(&mut self) { - if let Some((index, _)) = self - .options - .read() - .iter() - .find(|(_, state)| !state.disabled) - { - self.current_focus.set(Some(*index)); - } - } - - /// 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)); - } + /// Get the currently focused item ID (used for aria-activedescendant) + pub fn current_item_id(&self) -> Option { + let current_focus = self.focus_state.current_focus()?; + self.options + .get(¤t_focus) + .map(|state| state.id.clone()) } /// 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.current_focus() { + if let Some(focused_id) = self.focus_state.current_focus() { let options = self.options.read(); - if let Some(state) = options.get(&focused_index) { + if let Some(state) = options.get(&focused_id) { self.set_value.call(Some(state.value.clone())); self.open.set(false); } @@ -265,23 +147,21 @@ impl SelectContext { let keyboard = self.adaptive_keyboard.read(); if let Some(best_match_index) = - super::text_search::best_match(&keyboard, &typeahead, options.values()) + super::text_search::best_match(&keyboard, &typeahead, options.iter()) { - self.current_focus.set(Some(best_match_index)); + self.focus_state.set_focus(Some(best_match_index)); } } } /// State for individual select options pub(super) struct OptionState { - /// Tab index for focus management - pub tab_index: usize, + /// HTML ID for the option. + pub id: String, /// The value of the option pub value: RcPartialEqValue, /// Display text for the option 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/mod.rs b/primitives/src/select/mod.rs index c3066837..b97a2b1b 100644 --- a/primitives/src/select/mod.rs +++ b/primitives/src/select/mod.rs @@ -48,13 +48,13 @@ //! SelectGroup { //! SelectGroupLabel { "Fruits" } //! SelectOption:: { -//! index: 0usize, +//! tab_index: 0usize, //! value: "apple", //! "Apple" //! SelectItemIndicator { "✔️" } //! } //! SelectOption:: { -//! index: 1usize, +//! tab_index: 1usize, //! value: "banana", //! "Banana" //! SelectItemIndicator { "✔️" } diff --git a/primitives/src/select/text_search.rs b/primitives/src/select/text_search.rs index 2907e754..5a1d58f7 100644 --- a/primitives/src/select/text_search.rs +++ b/primitives/src/select/text_search.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; pub(super) fn best_match<'a>( keyboard: &AdaptiveKeyboard, typeahead: &str, - options: impl Iterator, + options: impl Iterator, ) -> Option { if typeahead.is_empty() { return None; @@ -17,12 +17,12 @@ pub(super) fn best_match<'a>( let typeahead_characters: Box<[_]> = typeahead.chars().collect(); options - .filter(|o| !o.disabled) - .map(|opt| { + .filter(|(_, o)| !o.disabled) + .map(|(&key, opt)| { let value = &opt.text_value; let value_characters: Box<[_]> = value.chars().collect(); let distance = normalized_distance(&typeahead_characters, &value_characters, keyboard); - (distance, opt.tab_index) + (distance, key) }) .min_by(|(d1, _), (d2, _)| f32::total_cmp(d1, d2)) .map(|(_, value)| value) @@ -533,29 +533,35 @@ mod tests { #[test] fn test_best_match() { - let options = vec![ - OptionState { - tab_index: 0, - 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, - }, - ]; + let options = HashMap::from([ + ( + 0, + OptionState { + value: RcPartialEqValue::new("apple"), + text_value: "Apple".to_string(), + id: "apple".to_string(), + disabled: false, + }, + ), + ( + 1, + OptionState { + value: RcPartialEqValue::new("banana"), + text_value: "Banana".to_string(), + id: "banana".to_string(), + disabled: false, + }, + ), + ( + 2, + OptionState { + value: RcPartialEqValue::new("cherry"), + text_value: "Cherry".to_string(), + id: "cherry".to_string(), + disabled: false, + }, + ), + ]); let layout = AdaptiveKeyboard::default(); @@ -602,22 +608,26 @@ mod tests { assert_eq!(adaptive.physical_mappings.get("KeyA"), Some(&'ф')); assert_eq!(adaptive.physical_mappings.get("KeyS"), Some(&'ы')); - let options = vec![ - OptionState { - tab_index: 0, - 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, - }, - ]; + let options = HashMap::from([ + ( + 0, + OptionState { + value: RcPartialEqValue::new("ф"), + text_value: "ф".to_string(), + id: "ф".to_string(), + disabled: false, + }, + ), + ( + 1, + OptionState { + value: RcPartialEqValue::new("banana"), + text_value: "Banana".to_string(), + id: "banana".to_string(), + disabled: false, + }, + ), + ]); // ы should be a closer match to ф than banana let result = best_match(&adaptive, "ф", options.iter()); diff --git a/primitives/src/tabs.rs b/primitives/src/tabs.rs index 0d4c9802..8b2fd447 100644 --- a/primitives/src/tabs.rs +++ b/primitives/src/tabs.rs @@ -299,7 +299,7 @@ pub fn TabTrigger(props: TabTriggerProps) -> Element { "-1" }); - let onmounted = use_focus_controlled_item(props.index); + let onmounted = use_focus_controlled_item(props.index.cloned(), props.index); rsx! { button { diff --git a/primitives/src/toggle_group.rs b/primitives/src/toggle_group.rs index 324e2bbe..729e195a 100644 --- a/primitives/src/toggle_group.rs +++ b/primitives/src/toggle_group.rs @@ -239,14 +239,14 @@ pub fn ToggleItem(props: ToggleItemProps) -> Element { return "0"; } - match ctx.focus.recent_focus_or_default() == props.index.cloned() { + match ctx.focus.recent_focus().unwrap_or_default() == props.index.cloned() { true => "0", false => "-1", } }); // Handle settings focus - let onmounted = use_focus_controlled_item(props.index); + let onmounted = use_focus_controlled_item(props.index.cloned(), props.index); rsx! { Toggle {