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/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/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/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/preview/src/components/select/variants/disabled/mod.rs b/preview/src/components/select/variants/disabled/mod.rs new file mode 100644 index 00000000..01c2e210 --- /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::> { + tab_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::> { + tab_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..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 {} } @@ -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 { @@ -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 d8fcb0dd..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 { "✔️" } @@ -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 019d9318..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 { "✔️" } @@ -163,9 +163,15 @@ pub fn SelectList(props: SelectListProps) -> Element { use_effect(move || { if render() { - ctx.focus_state.set_focus(ctx.initial_focus.cloned()); + if let Some(last) = (ctx.initial_focus_last)() { + if last { + ctx.focus_state.focus_last(); + } else { + ctx.focus_state.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..90e57863 100644 --- a/primitives/src/select/components/option.rs +++ b/primitives/src/select/components/option.rs @@ -1,15 +1,14 @@ //! 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::{ - 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 super::super::context::{OptionState, SelectContext, SelectOptionContext}; - /// The props for the [`SelectOption`] component #[derive(Props, Clone, PartialEq)] pub struct SelectOptionProps { @@ -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. - 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,27 +126,30 @@ 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(), + disabled, }; // Add the option to the context's options - ctx.options.write().push(option_state); + ctx.options.write().insert(focus_id, option_state); }); use_effect_cleanup(move || { - ctx.options.write().retain(|opt| opt.id != *id.read()); + ctx.options.write().remove(&focus_id); }); - 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 onmounted = use_focus_controlled_item_disabled(focus_id, tab_index, props.disabled); + + 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()) }); @@ -172,8 +174,16 @@ 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) { + 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); } @@ -229,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 6844b6c2..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::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; -use crate::focus::use_focus_provider; - /// 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(Vec::default); + 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); @@ -131,6 +130,7 @@ 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 || { @@ -143,22 +143,21 @@ pub fn Select(props: SelectProps) -> Element typeahead_buffer.take(); } }); - let initial_focus = use_signal(|| None); use_context_provider(|| SelectContext { typeahead_buffer, open, value, set_value, - options, adaptive_keyboard, list_id, - focus_state, disabled: props.disabled, placeholder: props.placeholder, typeahead_clear_task, typeahead_timeout: props.typeahead_timeout, - initial_focus, + focus_state, + options, + initial_focus_last, }); rsx! { diff --git a/primitives/src/select/components/trigger.rs b/primitives/src/select/components/trigger.rs index f6162f6a..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,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_item_id()); + rsx! { button { // Standard HTML attributes disabled: (ctx.disabled)(), - type: "button", + r#type: "button", onclick: move |_| { open.toggle(); @@ -82,13 +84,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(); } @@ -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 2fa55930..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 { "✔️" } @@ -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()) }) }); @@ -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 94a3aa6c..a40b5f88 100644 --- a/primitives/src/select/context.rs +++ b/primitives/src/select/context.rs @@ -1,13 +1,13 @@ //! 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 crate::focus::FocusState; +use std::collections::HashMap; +use std::{any::Any, rc::Rc, time::Duration}; trait DynPartialEq: Any { fn eq(&self, other: &dyn Any) -> bool; @@ -57,14 +57,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,19 +69,33 @@ 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 focus state for the select + pub focus_state: FocusState, + /// A list of options with their states + pub(crate) options: Signal>, + /// The initial element to focus once the list is rendered
+ /// true: last element
+ /// false: first element + pub initial_focus_last: Signal>, } impl SelectContext { + /// 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.focus_state.current_focus() { + if let Some(focused_id) = self.focus_state.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_id) { + self.set_value.call(Some(state.value.clone())); self.open.set(false); } } @@ -137,7 +147,7 @@ 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.iter()) { self.focus_state.set_focus(Some(best_match_index)); } @@ -146,14 +156,14 @@ impl SelectContext { /// 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, } /// Context for select option components to know if they're selected 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 bc311b96..5a1d58f7 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,12 +17,12 @@ pub(super) fn best_match( let typeahead_characters: Box<[_]> = typeahead.chars().collect(); options - .iter() - .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,43 +533,52 @@ 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(), - }, - OptionState { - tab_index: 1, - value: RcPartialEqValue::new("banana"), - text_value: "Banana".to_string(), - id: "banana".to_string(), - }, - OptionState { - tab_index: 2, - value: RcPartialEqValue::new("cherry"), - text_value: "Cherry".to_string(), - id: "cherry".to_string(), - }, - ]; + 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(); // 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()); } @@ -599,27 +608,33 @@ 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(), - }, - OptionState { - tab_index: 1, - value: RcPartialEqValue::new("banana"), - text_value: "Banana".to_string(), - id: "banana".to_string(), - }, - ]; + 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); + 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)); } 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 {