From 4848b756fdce02ba500206778525583ece999644 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Fri, 15 May 2026 14:44:14 +0300 Subject: [PATCH 01/10] [*] Tag Group: implement component --- Cargo.lock | 16 +- component.json | 3 +- playwright/tag_group.spec.ts | 22 + preview/src/components/mod.rs | 4 +- .../src/components/tag_group/component.json | 17 + preview/src/components/tag_group/component.rs | 113 +++++ preview/src/components/tag_group/docs.md | 14 + preview/src/components/tag_group/mod.rs | 2 + preview/src/components/tag_group/style.css | 80 +++ .../components/tag_group/variants/main/mod.rs | 39 ++ primitives/src/lib.rs | 1 + primitives/src/tag_group.rs | 475 ++++++++++++++++++ 12 files changed, 776 insertions(+), 10 deletions(-) create mode 100644 playwright/tag_group.spec.ts create mode 100644 preview/src/components/tag_group/component.json create mode 100644 preview/src/components/tag_group/component.rs create mode 100644 preview/src/components/tag_group/docs.md create mode 100644 preview/src/components/tag_group/mod.rs create mode 100644 preview/src/components/tag_group/style.css create mode 100644 preview/src/components/tag_group/variants/main/mod.rs create mode 100644 primitives/src/tag_group.rs diff --git a/Cargo.lock b/Cargo.lock index 53d54bc94..ddb7a55a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6124,18 +6124,18 @@ checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" [[package]] name = "pin-project" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", @@ -8375,7 +8375,7 @@ dependencies = [ "indexmap", "toml_datetime 1.0.1+spec-1.1.0", "toml_parser", - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -8384,7 +8384,7 @@ version = "1.0.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" dependencies = [ - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -10166,9 +10166,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] diff --git a/component.json b/component.json index c09861149..a80a3e54d 100644 --- a/component.json +++ b/component.json @@ -45,6 +45,7 @@ "preview/src/components/drag_and_drop_list", "preview/src/components/color_picker", "preview/src/components/combobox", - "preview/src/components/item" + "preview/src/components/item", + "preview/src/components/tag_group" ] } diff --git a/playwright/tag_group.spec.ts b/playwright/tag_group.spec.ts new file mode 100644 index 000000000..2e1992825 --- /dev/null +++ b/playwright/tag_group.spec.ts @@ -0,0 +1,22 @@ +import { test, expect } from "@playwright/test"; + +test("tag group selection and removal", async ({ page }) => { + await page.goto("http://127.0.0.1:8080/component/?name=tag_group&"); + + const bug = page.getByRole("row", { name: "bug" }); + const core = page.getByRole("row", { name: "core" }); + const feature = page.getByRole("row", { name: "feature" }); + + await expect(bug).toHaveAttribute("data-selected", "true"); + await expect(core).toHaveAttribute("data-selected", "false"); + + await core.click(); + await expect(core).toHaveAttribute("data-selected", "true"); + + await feature.click(); + await expect(feature).toHaveAttribute("data-selected", "false"); + await expect(feature).toHaveAttribute("data-disabled", "true"); + + await page.getByRole("button", { name: "Remove item bug" }).click(); + await expect(bug).toHaveCount(0); +}); diff --git a/preview/src/components/mod.rs b/preview/src/components/mod.rs index cad937b4d..f4753828f 100644 --- a/preview/src/components/mod.rs +++ b/preview/src/components/mod.rs @@ -35,7 +35,8 @@ impl ComponentCategory { pub fn category_of(name: &str) -> ComponentCategory { match name { "button" | "input" | "textarea" | "label" | "checkbox" | "switch" | "radio_group" - | "toggle" | "toggle_group" | "select" | "slider" | "calendar" | "date_picker" + | "toggle" | "toggle_group" | "tag_group" | "select" | "slider" | "calendar" + | "date_picker" | "color_picker" => ComponentCategory::Forms, "navbar" | "sidebar" | "tabs" | "pagination" | "menubar" | "toolbar" | "context_menu" | "dropdown_menu" => ComponentCategory::Navigation, @@ -206,6 +207,7 @@ examples!( slider[dynamic_range, range], switch, tabs, + tag_group, textarea[outline, fade, ghost], toast, toggle, diff --git a/preview/src/components/tag_group/component.json b/preview/src/components/tag_group/component.json new file mode 100644 index 000000000..534781a35 --- /dev/null +++ b/preview/src/components/tag_group/component.json @@ -0,0 +1,17 @@ +{ + "name": "tag_group", + "description": "A focusable list of different items.", + "authors": ["Evan Almloff"], + "exclude": ["variants", "docs.md", "component.json"], + "cargoDependencies": [ + { + "name": "dioxus-primitives", + "git": "https://github.com/DioxusLabs/components" + }, + { + "name": "dioxus-icons", + "version": "0.1.0" + } + ], + "globalAssets": ["../../../assets/dx-components-theme.css"] +} diff --git a/preview/src/components/tag_group/component.rs b/preview/src/components/tag_group/component.rs new file mode 100644 index 000000000..0b24ee8a8 --- /dev/null +++ b/preview/src/components/tag_group/component.rs @@ -0,0 +1,113 @@ +use dioxus::prelude::*; +use dioxus_icons::lucide::X; +use dioxus_primitives::tag_group::{ + self, TagGroupCtx, TagGroupProps, TagItemContext, TagListProps, TagProps, +}; +use std::collections::HashSet; + +#[css_module("/src/components/tag_group/style.css")] +struct Styles; + +#[component] +pub fn TagGroup(props: TagGroupProps) -> Element { + let items: Vec = props + .items + .iter() + .enumerate() + .map(|(idx, item)| { + let key = item + .as_ref() + .ok() + .and_then(|v| v.key.clone()) + .unwrap_or_else(|| idx.to_string()); + rsx! { + div { + class: Styles::dx_item_body_div, + key: "{key}", + {item} + } + } + }) + .collect(); + + rsx! { + tag_group::TagGroup { + class: Styles::dx_tag_group, + label: props.label, + items, + selection_mode: props.selection_mode, + selected_tags: props.selected_tags, + default_selected_tags: props.default_selected_tags, + on_selection_change: props.on_selection_change, + disabled_tags: props.disabled_tags, + disabled: props.disabled, + allows_empty_selection: props.allows_empty_selection, + escape_clears_selection: props.escape_clears_selection, + allows_removing: props.allows_removing, + roving_loop: props.roving_loop, + attributes: props.attributes, + TagList { {props.children} } + } + } +} + +#[component] +fn TagList(props: TagListProps) -> Element { + let ctx: TagGroupCtx = use_context(); + let is_removable = ctx.is_removable(); + + rsx! { + tag_group::TagList { + class: Styles::dx_tag_list, + attributes: props.attributes, + for item in tag_group::use_tag_list_items() { + Tag { + index: item.index, + {item.children} + if is_removable { + RemoveButton {} + } + } + } + {props.children} + } + } +} + +#[component] +pub fn Tag(props: TagProps) -> Element { + rsx! { + tag_group::Tag { + class: Styles::dx_tag, + index: props.index, + disabled: props.disabled, + attributes: props.attributes, + {props.children} + } + } +} + +#[component] +fn RemoveButton( + #[props(extends = GlobalAttributes)] attributes: Vec, + children: Element, +) -> Element { + let mut ctx: TagGroupCtx = use_context(); + let item_ctx: TagItemContext = use_context(); + let tag_key = item_ctx.key(); + + rsx! { + button { + class: Styles::dx_remove_button, + r#type: "button", + aria_label: format!("Remove item {tag_key}"), + onclick: move |e| { + e.stop_propagation(); + ctx.remove_tags(HashSet::from([tag_key.clone()])); + }, + ..attributes, + {children} + X { size: "12px" } + } + } +} diff --git a/preview/src/components/tag_group/docs.md b/preview/src/components/tag_group/docs.md new file mode 100644 index 000000000..86ba273eb --- /dev/null +++ b/preview/src/components/tag_group/docs.md @@ -0,0 +1,14 @@ +# Tag group + +A Tag Group is a focusable list of labels, categories, keywords, filters, or other items, with support for keyboard navigation, selection, and removal. + +## Structure + +```rust +TagGroup { + // Items to be rendered + items + // The type of selection that is allowed in the group. + selection_mode +} +``` diff --git a/preview/src/components/tag_group/mod.rs b/preview/src/components/tag_group/mod.rs new file mode 100644 index 000000000..2590c0132 --- /dev/null +++ b/preview/src/components/tag_group/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; diff --git a/preview/src/components/tag_group/style.css b/preview/src/components/tag_group/style.css new file mode 100644 index 000000000..fd2ba971f --- /dev/null +++ b/preview/src/components/tag_group/style.css @@ -0,0 +1,80 @@ +.dx-tag-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.dx-tag-list { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.dx-tag { + display: flex; + max-width: fit-content; + align-items: center; + border: 1px solid var(--primary-color-6); + border-radius: 9999px; + cursor: default; + font-size: 0.75rem; + gap: 0.25rem; + padding-block: 0.125rem; + padding-inline: 0.75rem; +} + +.dx-tag[data-selected="true"] { + border-color: var(--focused-border-color); + background-color: var(--focused-border-color); +} + +.dx-tag[data-disabled="true"] { + opacity: 0.5; + pointer-events: none; +} + +.dx-tag:focus-visible { + box-shadow: 0 0 0 2px var(--focused-border-color); +} + +.dx-item-body-div { + min-width: 0; + flex-grow: 1; + font-size: 14px; + font-weight: normal; + line-height: 20px; +} + +.dx-remove-button { + display: flex; + overflow: visible; + flex-shrink: 0; + align-items: center; + justify-content: center; + padding: 0; + border-style: none; + border-radius: 6px; + margin-left: 0.25rem; + background-color: transparent; + color: var(--secondary-color-6); + cursor: pointer; + transition: + background-color 120ms ease, + color 120ms ease, + transform 80ms ease; +} + +.dx-remove-button:hover { + background-color: var(--light, var(--primary-color-5)) + var(--dark, var(--primary-color-6)); + color: var(--secondary-color-2); +} + +.dx-remove-button:active { + transform: scale(0.92); +} + +.dx-remove-button:focus-visible { + outline: 2px solid var(--focused-border-color); + outline-offset: 2px; +} \ No newline at end of file diff --git a/preview/src/components/tag_group/variants/main/mod.rs b/preview/src/components/tag_group/variants/main/mod.rs new file mode 100644 index 000000000..d2f3b5a6f --- /dev/null +++ b/preview/src/components/tag_group/variants/main/mod.rs @@ -0,0 +1,39 @@ +use dioxus::prelude::*; + +use dioxus_primitives::tag_group::SelectionMode; +use super::super::component::*; +use std::collections::HashSet; + +#[component] +pub fn Demo() -> Element { + let items = [ + ("bug", "bug"), + ("feature", "feature"), + ("core", "core"), + ("desktop", "desktop"), + ("example", "example"), + ("duplicate", "duplicate"), + ] + .map(|(key, label)| { + rsx! { + span { key: "{key}", "{label}" } + } + }) + .to_vec(); + + let mut selected = use_signal(|| HashSet::from(["bug".into()])); + let selected_tags = use_memo(move || Some(selected())); + + rsx! { + TagGroup { + label: "Labels", + items, + selection_mode: SelectionMode::Multiple, + disabled_tags: HashSet::from(["feature".into(), "example".into()]), + selected_tags, + on_selection_change: move |tags| selected.set(tags), + allows_empty_selection: false, + allows_removing: true, + } + } +} diff --git a/primitives/src/lib.rs b/primitives/src/lib.rs index facfc1e63..5e2b584d0 100644 --- a/primitives/src/lib.rs +++ b/primitives/src/lib.rs @@ -51,6 +51,7 @@ pub mod tabs; pub mod toast; pub mod toggle; pub mod toggle_group; +pub mod tag_group; pub mod toolbar; pub mod tooltip; pub(crate) mod r#virtual; diff --git a/primitives/src/tag_group.rs b/primitives/src/tag_group.rs new file mode 100644 index 000000000..156e0def8 --- /dev/null +++ b/primitives/src/tag_group.rs @@ -0,0 +1,475 @@ +//! Defines the [`TagGroup`] component and its sub-components. + +use dioxus::prelude::*; + +use crate::focus::{use_focus_controlled_item_disabled, use_focus_provider, FocusState}; +use crate::{use_controlled, use_unique_id}; + +use std::collections::HashSet; + +/// The type of selection that is allowed in [`TagGroup`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SelectionMode { + /// No selection (`aria-selected` is not set). + #[default] + None, + /// At most one tag may be selected. + Single, + /// Any number of tags may be selected. + Multiple, +} + +fn element_key(element: &Element, index: usize) -> String { + element + .as_ref() + .ok() + .and_then(|vnode| vnode.key.clone()) + .unwrap_or_else(|| index.to_string()) +} + +/// Context provided by [`TagGroup`] to its descendants. +/// Use `use_context::()` to access list-level operations. +#[derive(Clone, Copy)] +pub struct TagGroupCtx { + // State + list_items: Signal>, + // ID of the element that labels this group + labeled_by: Signal>, + selection_mode: SelectionMode, + selected_tags: Memo>, + on_selection_change: Callback>, + disabled_tags: ReadSignal>, + + // Configuration + focus: FocusState, + group_disabled: ReadSignal, + allows_empty_selection: ReadSignal, + escape_clears_selection: ReadSignal, + allows_removing: ReadSignal, +} + +impl TagGroupCtx { + /// Returns whether tags in this group show a remove control and can be deleted. + pub fn is_removable(&self) -> bool { + (self.allows_removing)() + } + + fn is_tag_disabled(&self, key: &str) -> bool { + (self.group_disabled)() || (self.disabled_tags)().contains(key) + } + + fn is_tag_selected(&self, key: &str) -> bool { + (self.selected_tags)().contains(key) + } + + fn toggle_tag(&self, key: String) { + let allows_empty_selection = (self.allows_empty_selection)(); + let mut next = (self.selected_tags)().clone(); + match self.selection_mode { + SelectionMode::None => { + return; + } + SelectionMode::Single => { + if !next.contains(&key) { + next.clear(); + next.insert(key); + } else if allows_empty_selection || next.len() > 1 { + next.clear(); + } + } + SelectionMode::Multiple => { + if !next.contains(&key) { + next.insert(key); + } else if allows_empty_selection || next.len() > 1 { + next.remove(&key); + } + } + } + + self.on_selection_change.call(next); + } + + fn clear_selection(&self) { + match self.selection_mode { + SelectionMode::None => {} + SelectionMode::Single | SelectionMode::Multiple => { + if (self.escape_clears_selection)() { + self.on_selection_change.call(HashSet::new()); + } + } + } + } + + /// Removes tags with the given keys from the list and clears them from the current selection. + pub fn remove_tags(&mut self, keys: HashSet) { + if keys.is_empty() { + return; + } + + let mut list = (self.list_items)(); + let mut indices: Vec = list + .iter() + .enumerate() + .filter_map(|(index, element)| { + keys.contains(&element_key(element, index)) + .then_some(index) + }) + .collect(); + indices.sort_unstable_by(|a, b| b.cmp(a)); + for index in indices { + let _ = list.remove(index); + } + self.list_items.set(list); + + let mut selected = (self.selected_tags)().clone(); + for key in &keys { + selected.remove(key); + } + if selected != (self.selected_tags)() { + self.on_selection_change.call(selected); + } + } + + fn keyboard_remove(&mut self) { + if !(self.allows_removing)() + || self.selection_mode == SelectionMode::None + || (self.selected_tags)().is_empty() + { + return; + } + self.remove_tags((self.selected_tags)()); + } +} + +/// Context provided by [`Tag`] to its children. +/// Use `use_context::()` to access the current item's index and key. +#[derive(Clone, Copy)] +pub struct TagItemContext { + index: Signal, + key: Memo, +} + +impl TagItemContext { + /// Returns the index of the current tag in the list. + pub fn index(&self) -> usize { + (self.index)() + } + + /// Returns the stable key of the current tag (selection, disabled state, removal). + pub fn key(&self) -> String { + (self.key)() + } +} + +/// The props for the [`TagGroup`] component. +#[derive(Props, Clone, PartialEq)] +pub struct TagGroupProps { + /// Optional label above the tag group. + #[props(default)] + pub label: Option, + + /// Tag content to render inside [`TagList`]. + pub items: Vec, + + /// The type of selection that is allowed in the group. + #[props(default)] + pub selection_mode: SelectionMode, + + /// The currently selected tag keys (controlled). `None` means uncontrolled. + #[props(default)] + pub selected_tags: ReadSignal>>, + + /// The initial selected tag keys (uncontrolled). + #[props(default)] + pub default_selected_tags: HashSet, + + /// Handler that is called when the selection changes. + #[props(default)] + pub on_selection_change: Callback>, + + /// The tag keys that are disabled. These items cannot be selected, focused, or otherwise interacted with. + #[props(default = ReadSignal::new(Signal::new(HashSet::new())))] + pub disabled_tags: ReadSignal>, + + /// Whether the tag group is disabled. + #[props(default)] + pub disabled: ReadSignal, + + /// Whether the collection allows empty selection. + #[props(default = ReadSignal::new(Signal::new(true)))] + pub allows_empty_selection: ReadSignal, + + /// Whether pressing the ESC key should clear selection in the TagGroup or not. + #[props(default = ReadSignal::new(Signal::new(true)))] + pub escape_clears_selection: ReadSignal, + + /// Shows a remove control on tags and enables Delete/Backspace removal. + #[props(default)] + pub allows_removing: ReadSignal, + + /// Whether focus should loop around when reaching the end. + #[props(default = ReadSignal::new(Signal::new(true)))] + pub roving_loop: ReadSignal, + + /// Additional attributes to apply to the tag group element. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// The children of the tag group component. Defaults to [`TagList`]. + #[props(default)] + pub children: Option, +} + +/// # TagGroup +/// +/// A focusable group of tags with optional selection and removal. +/// Pass tag content via `items` and render them with [`TagList`] / [`Tag`], +/// similar to [`crate::drag_and_drop_list::DragAndDropList`]. +#[component] +pub fn TagGroup(props: TagGroupProps) -> Element { + let label_id = use_unique_id(); + let mut labeled_by = use_signal(|| None); + labeled_by.set(props.label.as_ref().map(|_| label_id())); + + let (selected_tags, set_selected_tags) = use_controlled( + props.selected_tags, + props.default_selected_tags.clone(), + props.on_selection_change, + ); + + let list_items = use_signal(|| props.items.clone()); + let focus = use_focus_provider(props.roving_loop); + + use_context_provider(|| TagGroupCtx { + labeled_by, + selection_mode: props.selection_mode, + selected_tags, + on_selection_change: set_selected_tags, + disabled_tags: props.disabled_tags, + list_items, + focus, + group_disabled: props.disabled, + allows_empty_selection: props.allows_empty_selection, + escape_clears_selection: props.escape_clears_selection, + allows_removing: props.allows_removing, + }); + + let children = props.children.unwrap_or_else(|| rsx! { TagList {} }); + + rsx! { + div { + ..props.attributes, + if let Some(label) = props.label { + span { + id: label_id(), + {label} + } + } + {children} + } + } +} + +/// Data for rendering a tag in [`TagList`]. +#[derive(Clone, PartialEq)] +pub struct TagListRenderItem { + /// The current index of this tag. + pub index: usize, + /// The stable key for this tag. + pub key: String, + /// The rendered tag children. + pub children: Element, +} + +/// Returns render data for the current tags in [`TagGroup`]. +pub fn use_tag_list_items() -> Vec { + let ctx: TagGroupCtx = use_context(); + (ctx.list_items)() + .into_iter() + .enumerate() + .map(|(index, children)| { + let key = element_key(&children, index); + TagListRenderItem { + index, + key, + children, + } + }) + .collect() +} + +/// The props for the [`TagList`] component. +#[derive(Props, Clone, PartialEq)] +pub struct TagListProps { + /// Additional attributes to apply to the tag list element. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// The children of the tag list component. Defaults to a [`Tag`] per item from [`TagGroup::items`]. + #[props(default)] + pub children: Option, +} + +/// The inner grid element for tags. Defaults to rendering one [`Tag`] per item. +#[component] +pub fn TagList(props: TagListProps) -> Element { + let ctx = use_context::(); + + let children = props.children.unwrap_or_else(|| { + rsx! { + for item in use_tag_list_items() { + Tag { + key: "{item.key}", + index: item.index, + {item.children} + } + } + } + }); + + rsx! { + div { + role: "grid", + aria_labelledby: ctx.labeled_by, + tabindex: "-1", + aria_multiselectable: if ctx.selection_mode == SelectionMode::Multiple { "true" }, + aria_colcount: "1", + ..props.attributes, + {children} + } + } +} + +/// The props for the [`Tag`] component. +#[derive(Props, Clone, PartialEq)] +pub struct TagProps { + /// The index of the tag in the list. + pub index: usize, + + /// Whether this tag is disabled in addition to group-level [`TagGroupProps::disabled_tags`]. + #[props(default)] + pub disabled: ReadSignal, + + /// Additional attributes to apply to the tag element. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// The children of the tag component. + pub children: Element, +} + +/// # Tag +/// +/// A single tag row inside [`TagList`]. Must be used within [`TagGroup`]. +#[component] +pub fn Tag(props: TagProps) -> Element { + let index = props.index; + let mut ctx = use_context::(); + + let tag_key = use_memo(move || { + (ctx.list_items)() + .get(index) + .map(|element| element_key(element, index)) + .unwrap_or_else(|| index.to_string()) + }); + + let mut item_ctx = use_context_provider(|| TagItemContext { + index: Signal::new(index), + key: tag_key, + }); + if *item_ctx.index.peek() != index { + item_ctx.index.set(index); + } + + let tabindex = use_memo(move || { + if !(ctx.focus.roving_loop)() { + return "0"; + } + if ctx.focus.recent_focus_or_default() == index { + "0" + } else { + "-1" + } + }); + + let is_selected = move || ctx.is_tag_selected(&tag_key()); + let is_disabled = move || ctx.is_tag_disabled(&tag_key()) || (props.disabled)(); + let index_signal = use_memo(move || index); + let onmounted = use_focus_controlled_item_disabled(index_signal, is_disabled); + + let onkeydown = move |e: Event| { + if is_disabled() { + return; + } + let event_key = e.key(); + let item_key = tag_key(); + let mut prevent_default = false; + + match event_key { + Key::Escape => { + ctx.clear_selection(); + prevent_default = true; + } + Key::Character(s) if s == " " => { + ctx.toggle_tag(item_key.clone()); + prevent_default = true; + } + Key::Enter => { + ctx.toggle_tag(item_key); + prevent_default = true; + } + Key::Backspace | Key::Delete => { + ctx.keyboard_remove(); + prevent_default = true; + } + Key::ArrowUp | Key::ArrowLeft => { + ctx.focus.focus_prev(); + prevent_default = true; + } + Key::ArrowDown | Key::ArrowRight => { + ctx.focus.focus_next(); + prevent_default = true; + } + Key::Home => { + ctx.focus.focus_first(); + prevent_default = true; + } + Key::End => { + ctx.focus.focus_last(); + prevent_default = true; + } + _ => {} + } + + if prevent_default { + e.prevent_default(); + } + }; + + rsx! { + div { + role: "row", + key: "{tag_key()}", + tabindex, + aria_selected: if ctx.selection_mode != SelectionMode::None { is_selected() }, + aria_disabled: is_disabled(), + "data-selected": is_selected(), + "data-disabled": is_disabled(), + onmounted, + onfocus: move |_| ctx.focus.set_focus(Some(index)), + onkeydown, + onclick: move |_| { + if !is_disabled() { + ctx.toggle_tag(tag_key()); + } + }, + ..props.attributes, + div { + role: "gridcell", + aria_colindex: "1", + display: "contents", + {props.children} + } + } + } +} From 169a16a0e126d6d6f81c0fd2f315cbb394de27fb Mon Sep 17 00:00:00 2001 From: Dmitry Date: Fri, 15 May 2026 15:49:05 +0300 Subject: [PATCH 02/10] [*] Tag Group: update docs, empty state render, use_list_data --- preview/src/components/mod.rs | 4 +- .../src/components/tag_group/component.json | 2 +- preview/src/components/tag_group/component.rs | 34 +- preview/src/components/tag_group/docs.md | 22 +- preview/src/components/tag_group/style.css | 8 - primitives/src/lib.rs | 1 + primitives/src/list_data.rs | 1205 +++++++++++++++++ primitives/src/tag_group.rs | 203 ++- 8 files changed, 1376 insertions(+), 103 deletions(-) create mode 100644 primitives/src/list_data.rs diff --git a/preview/src/components/mod.rs b/preview/src/components/mod.rs index f4753828f..3e337461b 100644 --- a/preview/src/components/mod.rs +++ b/preview/src/components/mod.rs @@ -35,7 +35,7 @@ impl ComponentCategory { pub fn category_of(name: &str) -> ComponentCategory { match name { "button" | "input" | "textarea" | "label" | "checkbox" | "switch" | "radio_group" - | "toggle" | "toggle_group" | "tag_group" | "select" | "slider" | "calendar" + | "toggle" | "toggle_group" | "select" | "slider" | "calendar" | "date_picker" | "color_picker" => ComponentCategory::Forms, "navbar" | "sidebar" | "tabs" | "pagination" | "menubar" | "toolbar" | "context_menu" @@ -46,7 +46,7 @@ pub fn category_of(name: &str) -> ComponentCategory { "toast" | "progress" | "skeleton" | "badge" => ComponentCategory::Feedback, "accordion" | "collapsible" => ComponentCategory::Disclosure, "avatar" | "card" | "separator" | "aspect_ratio" | "item" | "drag_and_drop_list" - | "virtual_list" | "scroll_area" => ComponentCategory::DataDisplay, + | "virtual_list" | "scroll_area" | "tag_group" => ComponentCategory::DataDisplay, _ => ComponentCategory::DataDisplay, } } diff --git a/preview/src/components/tag_group/component.json b/preview/src/components/tag_group/component.json index 534781a35..5d39fcb97 100644 --- a/preview/src/components/tag_group/component.json +++ b/preview/src/components/tag_group/component.json @@ -1,6 +1,6 @@ { "name": "tag_group", - "description": "A focusable list of different items.", + "description": "A focusable group of tags (labels, categories, filters and similar items).", "authors": ["Evan Almloff"], "exclude": ["variants", "docs.md", "component.json"], "cargoDependencies": [ diff --git a/preview/src/components/tag_group/component.rs b/preview/src/components/tag_group/component.rs index 0b24ee8a8..1faa3d25c 100644 --- a/preview/src/components/tag_group/component.rs +++ b/preview/src/components/tag_group/component.rs @@ -1,8 +1,6 @@ use dioxus::prelude::*; use dioxus_icons::lucide::X; -use dioxus_primitives::tag_group::{ - self, TagGroupCtx, TagGroupProps, TagItemContext, TagListProps, TagProps, -}; +use dioxus_primitives::tag_group::{self, TagGroupCtx, TagGroupProps, TagListProps, TagProps}; use std::collections::HashSet; #[css_module("/src/components/tag_group/style.css")] @@ -10,31 +8,11 @@ struct Styles; #[component] pub fn TagGroup(props: TagGroupProps) -> Element { - let items: Vec = props - .items - .iter() - .enumerate() - .map(|(idx, item)| { - let key = item - .as_ref() - .ok() - .and_then(|v| v.key.clone()) - .unwrap_or_else(|| idx.to_string()); - rsx! { - div { - class: Styles::dx_item_body_div, - key: "{key}", - {item} - } - } - }) - .collect(); - rsx! { tag_group::TagGroup { class: Styles::dx_tag_group, label: props.label, - items, + items: props.items, selection_mode: props.selection_mode, selected_tags: props.selected_tags, default_selected_tags: props.default_selected_tags, @@ -45,6 +23,7 @@ pub fn TagGroup(props: TagGroupProps) -> Element { escape_clears_selection: props.escape_clears_selection, allows_removing: props.allows_removing, roving_loop: props.roving_loop, + render_empty_state: props.render_empty_state, attributes: props.attributes, TagList { {props.children} } } @@ -62,10 +41,11 @@ fn TagList(props: TagListProps) -> Element { attributes: props.attributes, for item in tag_group::use_tag_list_items() { Tag { + key: "{item.key}", index: item.index, {item.children} if is_removable { - RemoveButton {} + RemoveButton { index: item.index } } } } @@ -89,12 +69,12 @@ pub fn Tag(props: TagProps) -> Element { #[component] fn RemoveButton( + index: usize, #[props(extends = GlobalAttributes)] attributes: Vec, children: Element, ) -> Element { let mut ctx: TagGroupCtx = use_context(); - let item_ctx: TagItemContext = use_context(); - let tag_key = item_ctx.key(); + let tag_key = ctx.item_key(index); rsx! { button { diff --git a/preview/src/components/tag_group/docs.md b/preview/src/components/tag_group/docs.md index 86ba273eb..7e9dc65eb 100644 --- a/preview/src/components/tag_group/docs.md +++ b/preview/src/components/tag_group/docs.md @@ -1,14 +1,28 @@ # Tag group -A Tag Group is a focusable list of labels, categories, keywords, filters, or other items, with support for keyboard navigation, selection, and removal. +A Tag Group is a focusable group of tags (labels, categories, filters and similar items) with keyboard navigation, optional selection and removal. ## Structure ```rust TagGroup { - // Items to be rendered - items + // Optional visible label for the group + label, + items, // The type of selection that is allowed in the group. - selection_mode + selection_mode, + // Controlled selection (keys match item `key` values) + selected_tags, + on_selection_change: move |tags| { /* ... */ }, + // Keys that cannot be selected or focused + disabled_tags, + // Show remove buttons; Delete/Backspace removes selected tags + allows_removing, + // Shown when `items` is empty + render_empty_state, } ``` + +## Item keys + +Each entry in `items` should set a vnode `key`. Keys are used for selection, disabled state, and removal. If `key` is omitted, the item index is used as a string. diff --git a/preview/src/components/tag_group/style.css b/preview/src/components/tag_group/style.css index fd2ba971f..efd50285e 100644 --- a/preview/src/components/tag_group/style.css +++ b/preview/src/components/tag_group/style.css @@ -37,14 +37,6 @@ box-shadow: 0 0 0 2px var(--focused-border-color); } -.dx-item-body-div { - min-width: 0; - flex-grow: 1; - font-size: 14px; - font-weight: normal; - line-height: 20px; -} - .dx-remove-button { display: flex; overflow: visible; diff --git a/primitives/src/lib.rs b/primitives/src/lib.rs index 5e2b584d0..41eda5c6f 100644 --- a/primitives/src/lib.rs +++ b/primitives/src/lib.rs @@ -30,6 +30,7 @@ pub mod dropdown_menu; mod focus; pub mod hover_card; pub mod label; +pub mod list_data; mod listbox; pub mod menubar; mod move_interaction; diff --git a/primitives/src/list_data.rs b/primitives/src/list_data.rs new file mode 100644 index 000000000..99d1633df --- /dev/null +++ b/primitives/src/list_data.rs @@ -0,0 +1,1205 @@ +//! List state for dynamic collections: items, selection, filtering, and list mutations. +//! +//! [`use_list_data`] keeps a list of items of type `T`, optional multi-select, and optional +//! filter text. Mutations (`append`, `remove`, `move_before`, …) update internal state; consumers +//! read the current collection from [`ListData::items`]. +//! +//! # Item keys +//! +//! Each row needs a stable string key for selection, removal, and reordering. Keys come from the +//! `key` attribute on the [`Element`] returned by [`ListOptions::to_element`] (see [`element_key`]). +//! If no key is set, the item index is used as a string. +//! +//! ```rust +//! use dioxus::prelude::*; +//! use dioxus_primitives::list_data::{use_list_data, ListOptions}; +//! use std::rc::Rc; +//! +//! #[derive(Clone, PartialEq)] +//! struct Row { id: u32, label: String } +//! +//! #[component] +//! fn Example() -> Element { +//! let list = use_list_data(ListOptions { +//! initial_items: vec![Row { id: 1, label: "News".into() }], +//! to_element: Rc::new(|row| rsx! { +//! span { key: "{row.id}", {row.label.clone()} } +//! }), +//! ..Default::default() +//! }); +//! +//! rsx! { +//! for (index, row) in (list.items)().into_iter().enumerate() { +//! div { key: "{list.item_key(index)}", {row.label} } +//! } +//! } +//! } +//! ``` +//! +//! # Selection +//! +//! Selection is controlled or uncontrolled via [`ListOptions::selected_keys`], +//! [`ListOptions::default_selected_keys`], and [`ListOptions::on_selected_keys_change`]. +//! Use [`ListSelection::All`] or [`ListSelection::Keys`] for the current value. +//! +//! # Filtering +//! +//! When [`ListOptions::filter`] is set, [`ListData::items`] returns only items that match +//! [`ListData::filter_text`]. The full list remains in internal state; filtered rows are a view. +//! +//! # Wiring to UI +//! +//! Call [`use_list_data`] in the parent component, read [`ListData::items`] in `rsx!`, and use +//! callbacks such as [`ListData::remove`] and [`ListData::append`] to update the list. + +use dioxus::prelude::*; +use std::collections::HashSet; +use std::rc::Rc; + +use crate::use_controlled; + +/// Returns the `key` attribute value for `element`, or `index` when unset. +/// +/// Used by [`use_list_data`] for stable row identity. +pub fn element_key(element: &Element, index: usize) -> String { + element + .as_ref() + .ok() + .and_then(|vnode| vnode.key.clone()) + .unwrap_or_else(|| index.to_string()) +} + +/// Stable string key for a row in the list. +pub type ListKey = String; + +/// Converts a list item to an [`Element`] (must set a `key` attribute for stable identity). +pub type ToElementFn = Rc Element>; + +/// Returns whether an item matches the current filter text. +pub type ListFilterFn = Rc bool>; + +/// Updates a list item from its previous value (used by [`UpdateValue::Map`]). +pub type ListItemUpdateFn = Rc T>; + +/// Either every item is selected or an explicit key set. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ListSelection { + /// All items are selected. + All, + /// Selected keys only. + Keys(HashSet), +} + +#[derive(Debug, Clone, PartialEq)] +struct ListState { + items: Vec, +} + +/// Options for [`use_list_data`]. +pub struct ListOptions { + /// Initial items in the list. + pub initial_items: Vec, + /// Renders an item as an [`Element`] with a `key` attribute value (see [`element_key`]). + pub to_element: ToElementFn, + /// The currently selected keys. `None` means uncontrolled. + pub selected_keys: ReadSignal>, + /// The default selected keys when uncontrolled. + pub default_selected_keys: ListSelection, + /// Called when the selection changes. + pub on_selected_keys_change: Callback, + /// The current filter text. `None` means uncontrolled. + pub filter_text: ReadSignal>, + /// The default filter text when uncontrolled. + pub default_filter_text: String, + /// Called when the filter text changes. + pub on_filter_text_change: Callback, + /// A function that returns whether a item matches the current filter text. + pub filter: Option>, +} + +impl Default for ListOptions { + fn default() -> Self { + Self { + initial_items: Vec::new(), + to_element: Rc::new(|_| rsx! { span {} }), + selected_keys: ReadSignal::new(Signal::new(None)), + default_selected_keys: ListSelection::Keys(HashSet::new()), + on_selected_keys_change: Callback::default(), + filter_text: ReadSignal::new(Signal::new(None)), + default_filter_text: String::new(), + on_filter_text_change: Callback::default(), + filter: None, + } + } +} + +/// New value for [`ListData::update`]. +#[derive(Clone)] +pub enum UpdateValue { + /// Replace the item. + Replace(T), + /// Update from the previous value. + Map(ListItemUpdateFn), +} + +/// Handle returned by [`use_list_data`]. Clone to share the same list state across components. +pub struct ListData { + to_element: ToElementFn, + /// The items in the list (filtered when [`ListOptions::filter`] is set). + pub items: Memo>, + /// The keys of the currently selected items in the list. + pub selected_keys: Memo, + /// Sets the selected keys. + pub set_selected_keys: Callback, + /// Adds the given keys to the current selected keys; pass [`ListSelection::All`] to select all keys. + pub add_keys_to_selection: Callback, + /// Removes the given keys from the current selected keys; [`ListSelection::All`] clears to an empty key set. + pub remove_keys_from_selection: Callback, + /// The current filter text. + pub filter_text: Memo, + /// Sets the filter text (used with [`ListOptions::filter`]). + pub set_filter_text: Callback, + /// Gets an item from the list by key. + pub get_item: Callback>, + /// Inserts items into the list at the given `index` (clamped to end). + pub insert: Callback<(usize, Vec)>, + /// Inserts items into the list before the item at the given `key` (or at start if the list is empty). + pub insert_before: Callback<(ListKey, Vec)>, + /// Inserts items into the list after the item at the given `key`. + pub insert_after: Callback<(ListKey, Vec)>, + /// Appends items to the list. + pub append: Callback>, + /// Prepends items to the list. + pub prepend: Callback>, + /// Removes items from the list by their keys. + pub remove: Callback>, + /// Removes all items from the list that are currently in the set of selected items. + pub remove_selected_items: Callback<()>, + /// Moves an item within the list. + pub r#move: Callback<(ListKey, usize)>, + /// Moves one or more items before a given `key`. + pub move_before: Callback<(ListKey, Vec)>, + /// Moves one or more items after a given `key`. + pub move_after: Callback<(ListKey, Vec)>, + /// Updates an item in the list. + pub update: Callback<(ListKey, UpdateValue)>, +} + +impl ListData { + /// Returns the stable key for the item at `index` (vnode `key`, or `index` as string). + pub fn item_key(&self, index: usize) -> String { + (self.items)() + .get(index) + .map(|item| item_key(item, self.to_element.as_ref(), index)) + .unwrap_or_else(|| index.to_string()) + } +} + +fn item_key(item: &T, to_element: &dyn Fn(&T) -> Element, index: usize) -> String { + element_key(&to_element(item), index) +} + +fn all_keys(items: &[T], to_element: &dyn Fn(&T) -> Element) -> HashSet { + items + .iter() + .enumerate() + .map(|(index, item)| item_key(item, to_element, index)) + .collect() +} + +fn find_index(items: &[T], to_element: &dyn Fn(&T) -> Element, key: &str) -> Option { + items + .iter() + .enumerate() + .find(|(index, item)| item_key(*item, to_element, *index) == key) + .map(|(index, _)| index) +} + +/// Move indices `indices` (sorted ascending) to bucket starting at `to_index` +fn move_indices( + mut items: Vec, + mut indices: Vec, + mut to_index: usize, +) -> Vec { + if indices.is_empty() { + return items; + } + indices.sort_unstable(); + to_index -= indices.iter().filter(|&&i| i < to_index).count(); + + let mut moves: Vec<(usize, usize)> = indices + .into_iter() + .enumerate() + .map(|(k, from)| (from, to_index + k)) + .collect(); + + for i in 0..moves.len() { + let a = moves[i].0; + for slot in moves.iter_mut().skip(i) { + if slot.0 > a { + slot.0 -= 1; + } + } + } + + for i in 0..moves.len() { + for j in (i + 1..moves.len()).rev() { + if moves[j].0 < moves[i].1 { + moves[i].1 += 1; + } else { + moves[j].0 += 1; + } + } + } + + for (from, to) in moves { + if from >= items.len() { + continue; + } + let item = items.remove(from); + let to = to.min(items.len()); + items.insert(to, item); + } + + items +} + +/// Creates list state for a dynamic collection. +/// +/// Must be called at the top level of a component (like any Dioxus hook). Returns [`ListData`] +/// with reactive [`ListData::items`], selection, filter text, and callbacks to mutate the list. +pub fn use_list_data(options: ListOptions) -> ListData { + let to_element = options.to_element.clone(); + let filter = options.filter.clone(); + + let state = use_signal(move || ListState { + items: options.initial_items, + }); + + let (selected_keys, set_selected_keys) = use_controlled( + options.selected_keys, + options.default_selected_keys, + options.on_selected_keys_change, + ); + + let (filter_text, set_filter_text) = use_controlled( + options.filter_text, + options.default_filter_text, + options.on_filter_text_change, + ); + + let items = use_memo({ + let filter = filter.clone(); + move || { + let s = state.read(); + match &filter { + Some(f) => s + .items + .iter() + .filter(|item| f(item, filter_text().as_str())) + .cloned() + .collect(), + None => s.items.clone(), + } + } + }); + + let add_keys_to_selection = use_callback({ + let to_element = to_element.clone(); + move |incoming: ListSelection| { + let valid_keys = all_keys(&state.read().items, to_element.as_ref()); + match selected_keys() { + ListSelection::All => (), + ListSelection::Keys(cur) => match incoming { + ListSelection::All => set_selected_keys.call(ListSelection::All), + ListSelection::Keys(extra) => { + let mut next = cur; + for k in extra { + if valid_keys.contains(&k) { + next.insert(k); + } + } + set_selected_keys.call(ListSelection::Keys(next)); + } + }, + } + } + }); + + let remove_keys_from_selection = use_callback({ + let to_element = to_element.clone(); + move |incoming: ListSelection| match incoming { + ListSelection::All => { + set_selected_keys.call(ListSelection::Keys(HashSet::new())); + } + ListSelection::Keys(to_remove) => { + let items = state.read().items.clone(); + let mut all = match selected_keys() { + ListSelection::All => all_keys(&items, to_element.as_ref()), + ListSelection::Keys(cur) => cur, + }; + for k in to_remove { + all.remove(&k); + } + set_selected_keys.call(ListSelection::Keys(all)); + } + } + }); + + let get_item = use_callback({ + let to_element = to_element.clone(); + move |key: ListKey| { + let s = state.read(); + let index = find_index(&s.items, to_element.as_ref(), &key)?; + s.items.get(index).cloned() + } + }); + + let insert = use_callback({ + let mut state = state; + move |(index, values): (usize, Vec)| { + let mut w = state.write(); + let idx = index.min(w.items.len()); + w.items.splice(idx..idx, values); + } + }); + + let insert_before = use_callback({ + let mut state = state; + let to_element = to_element.clone(); + move |(key, values): (ListKey, Vec)| { + let mut w = state.write(); + let idx = match find_index(&w.items, to_element.as_ref(), &key) { + Some(i) => i, + None if w.items.is_empty() => 0, + None => return, + }; + w.items.splice(idx..idx, values); + } + }); + + let insert_after = use_callback({ + let mut state = state; + let to_element = to_element.clone(); + move |(key, values): (ListKey, Vec)| { + let mut w = state.write(); + let idx = match find_index(&w.items, to_element.as_ref(), &key) { + Some(i) => i + 1, + None if w.items.is_empty() => 0, + None => return, + }; + w.items.splice(idx..idx, values); + } + }); + + let append = use_callback({ + let mut state = state; + move |values: Vec| { + let mut w = state.write(); + let len = w.items.len(); + w.items.splice(len..len, values); + } + }); + + let prepend = use_callback({ + let mut state = state; + move |values: Vec| { + let mut w = state.write(); + w.items.splice(0..0, values); + } + }); + + let remove = use_callback({ + let mut state = state; + let to_element = to_element.clone(); + move |keys: HashSet| { + let mut w = state.write(); + let keys_prune = keys.clone(); + let sel = selected_keys(); + let mut indices: Vec = w + .items + .iter() + .enumerate() + .filter_map(|(index, item)| { + keys.contains(&item_key(item, to_element.as_ref(), index)) + .then_some(index) + }) + .collect(); + indices.sort_unstable_by(|a, b| b.cmp(a)); + for index in indices { + let _ = w.items.remove(index); + } + + let next = if w.items.is_empty() { + ListSelection::Keys(HashSet::new()) + } else { + match sel { + ListSelection::All => ListSelection::All, + ListSelection::Keys(mut cur) => { + for k in keys_prune { + cur.remove(&k); + } + ListSelection::Keys(cur) + } + } + }; + set_selected_keys.call(next); + } + }); + + let remove_selected_items = use_callback({ + let mut state = state; + let to_element = to_element.clone(); + move |_| { + let mut w = state.write(); + match selected_keys() { + ListSelection::All => { + w.items.clear(); + } + ListSelection::Keys(sel) => { + let mut indices: Vec = w + .items + .iter() + .enumerate() + .filter_map(|(index, item)| { + sel.contains(&item_key(item, to_element.as_ref(), index)) + .then_some(index) + }) + .collect(); + indices.sort_unstable_by(|a, b| b.cmp(a)); + for index in indices { + w.items.remove(index); + } + } + } + set_selected_keys.call(ListSelection::Keys(HashSet::new())); + } + }); + + let move_one = use_callback({ + let mut state = state; + let to_element = to_element.clone(); + move |(key, to_index): (ListKey, usize)| { + let mut w = state.write(); + let Some(from) = find_index(&w.items, to_element.as_ref(), &key) else { + return; + }; + let mut items = std::mem::take(&mut w.items); + let item = items.remove(from); + let at = to_index.min(items.len()); + items.insert(at, item); + w.items = items; + } + }); + + let move_before = use_callback({ + let mut state = state; + let to_element = to_element.clone(); + move |(anchor_key, keys): (ListKey, Vec)| { + let mut w = state.write(); + let Some(to_index) = find_index(&w.items, to_element.as_ref(), &anchor_key) else { + return; + }; + let mut indices: Vec = keys + .iter() + .filter_map(|k| find_index(&w.items, to_element.as_ref(), k)) + .collect(); + indices.sort_unstable(); + w.items = move_indices(std::mem::take(&mut w.items), indices, to_index); + } + }); + + let move_after = use_callback({ + let mut state = state; + let to_element = to_element.clone(); + move |(anchor_key, keys): (ListKey, Vec)| { + let mut w = state.write(); + let Some(idx) = find_index(&w.items, to_element.as_ref(), &anchor_key) else { + return; + }; + let to_index = idx + 1; + let mut indices: Vec = keys + .iter() + .filter_map(|k| find_index(&w.items, to_element.as_ref(), k)) + .collect(); + indices.sort_unstable(); + w.items = move_indices(std::mem::take(&mut w.items), indices, to_index); + } + }); + + let update = use_callback({ + let mut state = state; + let to_element = to_element.clone(); + move |(key, new_value): (ListKey, UpdateValue)| { + let mut w = state.write(); + let Some(i) = find_index(&w.items, to_element.as_ref(), &key) else { + return; + }; + let updated = match new_value { + UpdateValue::Replace(v) => v, + UpdateValue::Map(f) => f(w.items[i].clone()), + }; + w.items[i] = updated; + } + }); + + ListData { + to_element, + items, + selected_keys, + filter_text, + set_selected_keys, + add_keys_to_selection, + remove_keys_from_selection, + set_filter_text, + get_item, + insert, + insert_before, + insert_after, + append, + prepend, + remove, + remove_selected_items, + r#move: move_one, + move_before, + move_after, + update, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + use std::collections::HashSet; + use std::panic::{catch_unwind, resume_unwind, AssertUnwindSafe}; + use std::rc::Rc; + + thread_local! { + static DOM_TEST_ERROR: RefCell> = const { RefCell::new(None) }; + } + + fn dom_test_fail(message: impl Into) { + DOM_TEST_ERROR.with(|slot| { + if slot.borrow().is_none() { + *slot.borrow_mut() = Some(message.into()); + } + }); + } + + fn dom_test_finish() { + DOM_TEST_ERROR.with(|slot| { + if let Some(message) = slot.borrow_mut().take() { + panic!("{message}"); + } + }); + } + + /// Like [`assert!`] inside `run_dom!` — records failure for the outer `#[test]`. + macro_rules! dom_assert { + ($cond:expr $(,)?) => { + if !$cond { + dom_test_fail(format!( + "assertion failed: {}\n at {}:{}", + stringify!($cond), + file!(), + line!(), + )); + } + }; + ($cond:expr, $($arg:tt)+) => { + if !$cond { + dom_test_fail(format!( + "assertion failed: {}\n at {}:{}", + format_args!($($arg)+), + file!(), + line!(), + )); + } + }; + } + + /// Like [`assert_eq!`] inside `run_dom!` — records failure for the outer `#[test]`. + macro_rules! dom_assert_eq { + ($left:expr, $right:expr $(,)?) => { + if $left != $right { + dom_test_fail(format!( + "assertion `left == right` failed\n left: {:?}\n right: {:?}\n at {}:{}", + $left, + $right, + file!(), + line!(), + )); + } + }; + } + + /// Run a `#[component]` that performs assertions during its first render. + macro_rules! run_dom { + ($component:path) => {{ + DOM_TEST_ERROR.with(|slot| *slot.borrow_mut() = None); + let result = catch_unwind(AssertUnwindSafe(|| { + let mut dom = VirtualDom::new($component); + dom.rebuild_in_place(); + })); + match result { + Ok(()) => dom_test_finish(), + Err(payload) => resume_unwind(payload), + } + }}; + } + + #[derive(Clone, PartialEq, Debug)] + struct Row { + id: String, + n: i32, + } + + fn row(id: &str, n: i32) -> Row { + Row { + id: id.to_string(), + n, + } + } + + fn row_to_element(row: &Row) -> Element { + rsx! { + span { key: "{row.id}", "{row.id}" } + } + } + + fn item_keys(list: &ListData) -> Vec { + let items = (list.items)(); + (0..items.len()).map(|index| list.item_key(index)).collect() + } + + fn test_options( + initial_items: Vec, + default_selected_keys: ListSelection, + filter: Option>, + ) -> ListOptions { + ListOptions { + initial_items, + to_element: Rc::new(row_to_element), + default_selected_keys, + filter, + ..Default::default() + } + } + + fn empty_keys() -> ListSelection { + ListSelection::Keys(HashSet::new()) + } + + #[component] + fn empty_data() -> Element { + let list = use_list_data(test_options(vec![], empty_keys(), None)); + dom_assert!((list.items)().is_empty()); + dom_assert!(matches!( + (list.selected_keys)(), + ListSelection::Keys(k) if k.is_empty() + )); + dom_assert_eq!((list.filter_text)(), ""); + rsx! { + div {} + } + } + + #[component] + fn no_selection() -> Element { + let list = use_list_data(test_options( + vec![row("a", 1), row("b", 2), row("c", 3)], + ListSelection::Keys(HashSet::new()), + None, + )); + dom_assert_eq!(item_keys(&list), vec!["a", "b", "c"]); + match (list.selected_keys)() { + ListSelection::Keys(k) => dom_assert!(k.is_empty()), + ListSelection::All => panic!("expected Keys"), + } + rsx! { + div {} + } + } + + #[component] + fn partial_selection() -> Element { + let list = use_list_data(test_options( + vec![row("a", 1), row("b", 2), row("c", 3)], + ListSelection::Keys(HashSet::from(["b".into()])), + None, + )); + dom_assert_eq!(item_keys(&list), vec!["a", "b", "c"]); + match (list.selected_keys)() { + ListSelection::Keys(k) => { + dom_assert_eq!(k.len(), 1); + dom_assert!(k.contains("b")); + } + ListSelection::All => panic!("expected Keys"), + } + rsx! { + div {} + } + } + + #[component] + fn selected_all() -> Element { + let list = use_list_data(test_options( + vec![row("a", 1), row("b", 2), row("c", 3)], + ListSelection::All, + None, + )); + dom_assert_eq!(item_keys(&list), vec!["a", "b", "c"]); + match (list.selected_keys)() { + ListSelection::Keys(_) => panic!("expected All"), + ListSelection::All => (), + } + rsx! { + div {} + } + } + + #[component] + fn update_selection() -> Element { + let list = use_list_data(test_options( + vec![row("a", 1), row("b", 2), row("c", 3)], + ListSelection::Keys(HashSet::new()), + None, + )); + + dom_assert_eq!(item_keys(&list), vec!["a", "b", "c"]); + match (list.selected_keys)() { + ListSelection::Keys(k) => dom_assert!(k.is_empty()), + ListSelection::All => panic!("expected Keys"), + } + + list.set_selected_keys.call(ListSelection::All); + match (list.selected_keys)() { + ListSelection::Keys(_) => panic!("expected All"), + ListSelection::All => (), + }; + + list.remove_keys_from_selection + .call(ListSelection::Keys(HashSet::from(["z".into()]))); + match (list.selected_keys)() { + ListSelection::Keys(k) => { + dom_assert_eq!(k.len(), 3); + dom_assert!(k.contains("b")); + } + ListSelection::All => panic!("expected Keys"), + }; + + list.remove_keys_from_selection + .call(ListSelection::Keys(HashSet::from(["b".into()]))); + match (list.selected_keys)() { + ListSelection::Keys(k) => { + dom_assert_eq!(k.len(), 2); + dom_assert!(k.contains("a")); + dom_assert!(k.contains("c")); + } + ListSelection::All => panic!("expected Keys"), + }; + + rsx! { + div {} + } + } + + #[component] + fn append_prepend_insert() -> Element { + let list = use_list_data(test_options(vec![], empty_keys(), None)); + list.insert_before + .call(("missing".into(), vec![row("m", 1)])); + dom_assert_eq!(item_keys(&list), vec!["m"]); + + list.append.call(vec![row("a", 1)]); + dom_assert_eq!(item_keys(&list), vec!["m", "a"]); + + list.prepend.call(vec![row("z", 9)]); + dom_assert_eq!(item_keys(&list), vec!["z", "m", "a"]); + + list.insert.call((1, vec![row("x", 5)])); + dom_assert_eq!(item_keys(&list), vec!["z", "x", "m", "a"]); + + list.insert.call((10, vec![row("v", 5)])); + dom_assert_eq!(item_keys(&list), vec!["z", "x", "m", "a", "v"]); + + list.insert_before + .call(("m".into(), vec![row("before_m", -1)])); + dom_assert_eq!(item_keys(&list), vec!["z", "x", "before_m", "m", "a", "v"]); + + list.insert_before + .call(("m2".into(), vec![row("failed", -1)])); + dom_assert_eq!(item_keys(&list), vec!["z", "x", "before_m", "m", "a", "v"]); + + list.insert_after + .call(("m".into(), vec![row("after_m", 2)])); + dom_assert_eq!( + item_keys(&list), + vec!["z", "x", "before_m", "m", "after_m", "a", "v"] + ); + + list.insert_after + .call(("m2".into(), vec![row("failed", -1)])); + dom_assert_eq!( + item_keys(&list), + vec!["z", "x", "before_m", "m", "after_m", "a", "v"] + ); + + rsx! { + div {} + } + } + + #[component] + fn remove_and_get_item() -> Element { + let list = use_list_data(test_options( + vec![row("a", 1), row("b", 2), row("c", 3)], + empty_keys(), + None, + )); + dom_assert_eq!(list.get_item.call("b".into()), Some(row("b", 2))); + dom_assert_eq!(list.get_item.call("z".into()), None::); + + list.remove.call(HashSet::from(["b".into(), "c".into()])); + dom_assert_eq!(item_keys(&list), vec!["a"]); + + list.remove.call(HashSet::from(["z".into()])); + dom_assert_eq!(item_keys(&list), vec!["a"]); + + list.add_keys_to_selection.call(ListSelection::All); + list.remove.call(HashSet::from(["a".into()])); + dom_assert!((list.items)().is_empty()); + dom_assert!(matches!( + (list.selected_keys)(), + ListSelection::Keys(k) if k.is_empty() + )); + + rsx! { + div {} + } + } + + #[component] + fn selection_set_add_remove() -> Element { + let list = use_list_data(test_options( + vec![row("a", 1), row("b", 2)], + ListSelection::Keys(HashSet::from(["a".into()])), + None, + )); + + match (list.selected_keys)() { + ListSelection::Keys(k) => { + dom_assert!(k.len() == 1 && k.contains("a")); + } + _ => panic!("expected Keys"), + } + + list.add_keys_to_selection + .call(ListSelection::Keys(HashSet::from(["b".into()]))); + match (list.selected_keys)() { + ListSelection::Keys(k) => { + dom_assert!(k.contains("a") && k.contains("b")); + } + _ => panic!("expected Keys"), + } + + list.add_keys_to_selection + .call(ListSelection::Keys(HashSet::from(["c".into(), "z".into()]))); + match (list.selected_keys)() { + ListSelection::Keys(k) => dom_assert_eq!(k.len(), 2), + _ => panic!("expected Keys"), + } + + list.add_keys_to_selection.call(ListSelection::All); + dom_assert!(matches!((list.selected_keys)(), ListSelection::All)); + + list.remove_keys_from_selection + .call(ListSelection::Keys(HashSet::from(["a".into()]))); + match (list.selected_keys)() { + ListSelection::Keys(k) => dom_assert!(!k.contains("a")), + _ => panic!("expected Keys after remove subset from All expansion"), + } + + list.set_selected_keys + .call(ListSelection::Keys(HashSet::from(["b".into()]))); + match (list.selected_keys)() { + ListSelection::Keys(k) => { + dom_assert_eq!(k.len(), 1); + dom_assert!(k.contains("b")); + } + _ => panic!("expected Keys"), + } + + list.set_selected_keys + .call(ListSelection::Keys(HashSet::from(["v".into()]))); + match (list.selected_keys)() { + ListSelection::Keys(k) => dom_assert_eq!(k.len(), 1), + _ => panic!("expected Keys"), + } + + list.remove_keys_from_selection.call(ListSelection::All); + match (list.selected_keys)() { + ListSelection::Keys(k) => dom_assert!(k.is_empty()), + _ => panic!("expected empty Keys"), + } + rsx! { + div {} + } + } + + #[component] + fn filter_and_filter_text() -> Element { + let list = use_list_data(test_options( + vec![row("apple", 1), row("banana", 2), row("apricot", 3)], + empty_keys(), + Some(Rc::new(|r: &Row, q: &str| { + r.id.to_lowercase().contains(&q.to_lowercase()) + })), + )); + dom_assert_eq!((list.items)().len(), 3); + + list.set_filter_text.call("ap".into()); + dom_assert_eq!((list.filter_text)(), "ap"); + dom_assert_eq!((list.items)().len(), 2); + + list.set_filter_text.call(String::new()); + dom_assert_eq!((list.filter_text)(), ""); + dom_assert_eq!((list.items)().len(), 3); + + list.set_filter_text.call("42".into()); + dom_assert_eq!((list.filter_text)(), "42"); + dom_assert!((list.items)().is_empty()); + + rsx! { + div {} + } + } + + #[component] + fn remove_selected_items() -> Element { + let list = use_list_data(test_options( + vec![row("a", 1), row("b", 2), row("c", 3)], + ListSelection::Keys(HashSet::from(["a".into(), "c".into()])), + None, + )); + list.remove_selected_items.call(()); + dom_assert_eq!(item_keys(&list), vec!["b"]); + dom_assert!(matches!( + (list.selected_keys)(), + ListSelection::Keys(k) if k.is_empty() + )); + + let list = use_list_data(test_options( + vec![row("x", 5), row("y", 2)], + ListSelection::All, + None, + )); + list.remove_selected_items.call(()); + dom_assert!((list.items)().is_empty()); + rsx! { + div {} + } + } + + #[component] + fn move_single() -> Element { + let list = use_list_data(test_options( + vec![row("a", 1), row("b", 2), row("c", 3)], + empty_keys(), + None, + )); + list.r#move.call(("c".into(), 0)); + dom_assert_eq!(item_keys(&list), vec!["c", "a", "b"]); + + list.r#move.call(("v".into(), 1)); + dom_assert_eq!(item_keys(&list), vec!["c", "a", "b"]); + + list.r#move.call(("c".into(), 7)); + dom_assert_eq!(item_keys(&list), vec!["a", "b", "c"]); + + rsx! { + div {} + } + } + + #[component] + fn move_before() -> Element { + let list = use_list_data(test_options( + vec![ + row("a", 1), + row("b", 2), + row("c", 3), + row("d", 4), + row("e", 5), + ], + empty_keys(), + None, + )); + + list.move_before + .call(("f".into(), vec!["b".into(), "e".into()])); + dom_assert_eq!(item_keys(&list), vec!["a", "b", "c", "d", "e"]); + + list.move_before + .call(("d".into(), vec!["b".into(), "c".into()])); + dom_assert_eq!(item_keys(&list), vec!["a", "b", "c", "d", "e"]); + + list.move_before + .call(("c".into(), vec!["b".into(), "e".into()])); + dom_assert_eq!(item_keys(&list), vec!["a", "b", "e", "c", "d"]); + + list.move_before.call(("b".into(), vec!["c".into()])); + dom_assert_eq!(item_keys(&list), vec!["a", "c", "b", "e", "d"]); + + rsx! { + div {} + } + } + + #[component] + fn move_after() -> Element { + let list = use_list_data(test_options( + vec![ + row("a", 1), + row("b", 2), + row("c", 3), + row("d", 4), + row("e", 5), + ], + empty_keys(), + None, + )); + + list.move_after + .call(("f".into(), vec!["b".into(), "e".into()])); + dom_assert_eq!(item_keys(&list), vec!["a", "b", "c", "d", "e"]); + + list.move_after + .call(("b".into(), vec!["e".into(), "d".into()])); + dom_assert_eq!(item_keys(&list), vec!["a", "b", "c", "d", "e"]); + + list.move_after + .call(("c".into(), vec!["e".into(), "b".into()])); + dom_assert_eq!(item_keys(&list), vec!["a", "c", "b", "e", "d"]); + + list.move_after.call(("b".into(), vec!["a".into()])); + dom_assert_eq!(item_keys(&list), vec!["c", "b", "a", "e", "d"]); + + rsx! { + div {} + } + } + + #[component] + fn update_replace_and_map() -> Element { + let list = use_list_data(test_options( + vec![row("a", 1), row("b", 2)], + empty_keys(), + None, + )); + list.update + .call(("b".into(), UpdateValue::Replace(row("c", 99)))); + dom_assert_eq!((list.items)(), vec![row("a", 1), row("c", 99)]); + + list.update + .call(("b".into(), UpdateValue::Replace(row("b", 99)))); + dom_assert_eq!((list.items)(), vec![row("a", 1), row("c", 99)]); + + list.update.call(( + "a".into(), + UpdateValue::Map(Rc::new(|prev: Row| Row { + n: prev.n + 10, + ..prev + })), + )); + dom_assert_eq!((list.items)(), vec![row("a", 11), row("c", 99)]); + rsx! { + div {} + } + } + + #[test] + fn test_empty_data() { + run_dom!(empty_data); + } + + #[test] + fn test_no_selection() { + run_dom!(no_selection); + } + + #[test] + fn test_partial_selection() { + run_dom!(partial_selection); + } + + #[test] + fn test_selected_all() { + run_dom!(selected_all); + } + + #[test] + fn test_update_selection() { + run_dom!(update_selection); + } + + #[test] + fn test_append_prepend_insert_before_after() { + run_dom!(append_prepend_insert); + } + + #[test] + fn test_remove_and_get_item() { + run_dom!(remove_and_get_item); + } + + #[test] + fn test_set_add_remove_selected_keys() { + run_dom!(selection_set_add_remove); + } + + #[test] + fn test_filter() { + run_dom!(filter_and_filter_text); + } + + #[test] + fn test_remove_selected_items() { + run_dom!(remove_selected_items); + } + + #[test] + fn test_move_one_item() { + run_dom!(move_single); + } + + #[test] + fn test_move_before() { + run_dom!(move_before); + } + + #[test] + fn test_move_after() { + run_dom!(move_after); + } + + #[test] + fn test_update_replace_and_map() { + run_dom!(update_replace_and_map); + } + + #[component] + fn failing_dom_assert() -> Element { + dom_assert_eq!(1, 2); + rsx! { + div {} + } + } + + #[test] + #[should_panic(expected = "assertion `left == right` failed")] + fn run_dom_propagates_failed_assertion() { + run_dom!(failing_dom_assert); + } +} diff --git a/primitives/src/tag_group.rs b/primitives/src/tag_group.rs index 156e0def8..c430d219b 100644 --- a/primitives/src/tag_group.rs +++ b/primitives/src/tag_group.rs @@ -3,6 +3,7 @@ use dioxus::prelude::*; use crate::focus::{use_focus_controlled_item_disabled, use_focus_provider, FocusState}; +use crate::list_data::element_key; use crate::{use_controlled, use_unique_id}; use std::collections::HashSet; @@ -19,14 +20,6 @@ pub enum SelectionMode { Multiple, } -fn element_key(element: &Element, index: usize) -> String { - element - .as_ref() - .ok() - .and_then(|vnode| vnode.key.clone()) - .unwrap_or_else(|| index.to_string()) -} - /// Context provided by [`TagGroup`] to its descendants. /// Use `use_context::()` to access list-level operations. #[derive(Clone, Copy)] @@ -46,6 +39,7 @@ pub struct TagGroupCtx { allows_empty_selection: ReadSignal, escape_clears_selection: ReadSignal, allows_removing: ReadSignal, + render_empty_state: Callback<(), Element>, } impl TagGroupCtx { @@ -54,6 +48,14 @@ impl TagGroupCtx { (self.allows_removing)() } + /// Returns the stable key for the tag at `index` + pub fn item_key(&self, index: usize) -> String { + (self.list_items)() + .get(index) + .map(|element| element_key(element, index)) + .unwrap_or_else(|| index.to_string()) + } + fn is_tag_disabled(&self, key: &str) -> bool { (self.group_disabled)() || (self.disabled_tags)().contains(key) } @@ -111,8 +113,7 @@ impl TagGroupCtx { .iter() .enumerate() .filter_map(|(index, element)| { - keys.contains(&element_key(element, index)) - .then_some(index) + keys.contains(&element_key(element, index)).then_some(index) }) .collect(); indices.sort_unstable_by(|a, b| b.cmp(a)); @@ -141,26 +142,6 @@ impl TagGroupCtx { } } -/// Context provided by [`Tag`] to its children. -/// Use `use_context::()` to access the current item's index and key. -#[derive(Clone, Copy)] -pub struct TagItemContext { - index: Signal, - key: Memo, -} - -impl TagItemContext { - /// Returns the index of the current tag in the list. - pub fn index(&self) -> usize { - (self.index)() - } - - /// Returns the stable key of the current tag (selection, disabled state, removal). - pub fn key(&self) -> String { - (self.key)() - } -} - /// The props for the [`TagGroup`] component. #[derive(Props, Clone, PartialEq)] pub struct TagGroupProps { @@ -211,6 +192,10 @@ pub struct TagGroupProps { #[props(default = ReadSignal::new(Signal::new(true)))] pub roving_loop: ReadSignal, + /// Renders content when [`TagGroupProps::items`] is empty. + #[props(default = Callback::new(|_| rsx! { div { "No tags" }}))] + pub render_empty_state: Callback<(), Element>, + /// Additional attributes to apply to the tag group element. #[props(extends = GlobalAttributes)] pub attributes: Vec, @@ -223,8 +208,40 @@ pub struct TagGroupProps { /// # TagGroup /// /// A focusable group of tags with optional selection and removal. -/// Pass tag content via `items` and render them with [`TagList`] / [`Tag`], -/// similar to [`crate::drag_and_drop_list::DragAndDropList`]. +/// Pass tag content via [`TagGroupProps::items`] (set a vnode `key` on each item) +/// and render them with [`TagList`] / [`Tag`], or customize the list in `children`. +/// +/// ## Example +/// +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::tag_group::{TagGroup, SelectionMode}; +/// use std::collections::HashSet; +/// +/// #[component] +/// fn Demo() -> Element { +/// let items = ["bug", "feature"] +/// .into_iter() +/// .map(|label| rsx! { +/// span { key: "{label}", {label} } +/// }) +/// .collect::>(); +/// +/// let mut selected = use_signal(|| HashSet::from(["bug".to_string()])); +/// let selected_tags = use_memo(move || Some(selected())); +/// +/// rsx! { +/// TagGroup { +/// label: "Labels", +/// items, +/// selection_mode: SelectionMode::Multiple, +/// selected_tags, +/// on_selection_change: move |tags| selected.set(tags), +/// allows_removing: true, +/// } +/// } +/// } +/// ``` #[component] pub fn TagGroup(props: TagGroupProps) -> Element { let label_id = use_unique_id(); @@ -252,6 +269,7 @@ pub fn TagGroup(props: TagGroupProps) -> Element { allows_empty_selection: props.allows_empty_selection, escape_clears_selection: props.escape_clears_selection, allows_removing: props.allows_removing, + render_empty_state: props.render_empty_state, }); let children = props.children.unwrap_or_else(|| rsx! { TagList {} }); @@ -287,13 +305,10 @@ pub fn use_tag_list_items() -> Vec { (ctx.list_items)() .into_iter() .enumerate() - .map(|(index, children)| { - let key = element_key(&children, index); - TagListRenderItem { - index, - key, - children, - } + .map(|(index, children)| TagListRenderItem { + index, + key: ctx.item_key(index), + children, }) .collect() } @@ -310,10 +325,50 @@ pub struct TagListProps { pub children: Option, } -/// The inner grid element for tags. Defaults to rendering one [`Tag`] per item. +/// # TagList +/// +/// The inner grid element for tags inside a [`TagGroup`]. +/// Renders with one [`Tag`] per row by default. When [`TagGroupProps::items`] is empty, shows +/// [`TagGroupProps::render_empty_state`] instead of the list. +/// +/// This must be used inside a [`TagGroup`] component. +/// +/// ## Example +/// +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::tag_group::{Tag, TagGroup, TagList, use_tag_list_items}; +/// +/// #[component] +/// fn Demo() -> Element { +/// let items = ["bug", "feature"] +/// .into_iter() +/// .map(|label| rsx! { +/// span { key: "{label}", {label} } +/// }) +/// .collect::>(); +/// +/// rsx! { +/// TagGroup { +/// label: "Labels", +/// items, +/// TagList { +/// for item in use_tag_list_items() { +/// Tag { +/// key: "{item.key}", +/// index: item.index, +/// {item.children} +/// } +/// } +/// } +/// } +/// } +/// } +/// ``` #[component] pub fn TagList(props: TagListProps) -> Element { let ctx = use_context::(); + let is_empty = (ctx.list_items)().is_empty(); let children = props.children.unwrap_or_else(|| { rsx! { @@ -335,7 +390,11 @@ pub fn TagList(props: TagListProps) -> Element { aria_multiselectable: if ctx.selection_mode == SelectionMode::Multiple { "true" }, aria_colcount: "1", ..props.attributes, - {children} + if is_empty { + {ctx.render_empty_state.call(())} + } else { + {children} + } } } } @@ -360,26 +419,50 @@ pub struct TagProps { /// # Tag /// -/// A single tag row inside [`TagList`]. Must be used within [`TagGroup`]. +/// A single tag row inside [`TagList`]. +/// Handles focus, selection (Space/Enter), arrow-key navigation, and removal +/// (Delete/Backspace when [`TagGroupProps::allows_removing`] is enabled). +/// +/// Pass the list index from [`use_tag_list_items`] or from your own `enumerate()`. +/// This must be used within a [`TagGroup`] component. +/// +/// ## Example +/// +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::tag_group::{Tag, TagGroup, TagList, use_tag_list_items}; +/// +/// #[component] +/// fn Demo() -> Element { +/// let items = [rsx! { span { key: "{0}", "bug" } }].to_vec(); +/// +/// rsx! { +/// TagGroup { +/// items, +/// TagList { +/// for item in use_tag_list_items() { +/// Tag { +/// key: "{item.key}", +/// index: item.index, +/// {item.children} +/// } +/// } +/// } +/// } +/// } +/// } +/// ``` +/// +/// ## Styling +/// +/// The [`Tag`] component defines the following data attributes you can use to control styling: +/// - `data-selected`: `true` when the tag is selected. +/// - `data-disabled`: `true` when the tag is disabled. #[component] pub fn Tag(props: TagProps) -> Element { let index = props.index; let mut ctx = use_context::(); - - let tag_key = use_memo(move || { - (ctx.list_items)() - .get(index) - .map(|element| element_key(element, index)) - .unwrap_or_else(|| index.to_string()) - }); - - let mut item_ctx = use_context_provider(|| TagItemContext { - index: Signal::new(index), - key: tag_key, - }); - if *item_ctx.index.peek() != index { - item_ctx.index.set(index); - } + let tag_key = move || ctx.item_key(index); let tabindex = use_memo(move || { if !(ctx.focus.roving_loop)() { @@ -402,7 +485,6 @@ pub fn Tag(props: TagProps) -> Element { return; } let event_key = e.key(); - let item_key = tag_key(); let mut prevent_default = false; match event_key { @@ -411,11 +493,11 @@ pub fn Tag(props: TagProps) -> Element { prevent_default = true; } Key::Character(s) if s == " " => { - ctx.toggle_tag(item_key.clone()); + ctx.toggle_tag(tag_key()); prevent_default = true; } Key::Enter => { - ctx.toggle_tag(item_key); + ctx.toggle_tag(tag_key()); prevent_default = true; } Key::Backspace | Key::Delete => { @@ -449,7 +531,6 @@ pub fn Tag(props: TagProps) -> Element { rsx! { div { role: "row", - key: "{tag_key()}", tabindex, aria_selected: if ctx.selection_mode != SelectionMode::None { is_selected() }, aria_disabled: is_disabled(), From 3a10eccb165066cfd549d96ba6cc7edbf84b79bd Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 16 May 2026 11:32:08 +0300 Subject: [PATCH 03/10] [*] Tag Group: update playwright tests --- playwright/tag_group.spec.ts | 153 +++++++++++++++++++++++++++++++---- 1 file changed, 138 insertions(+), 15 deletions(-) diff --git a/playwright/tag_group.spec.ts b/playwright/tag_group.spec.ts index 2e1992825..eeeb8a0b3 100644 --- a/playwright/tag_group.spec.ts +++ b/playwright/tag_group.spec.ts @@ -1,22 +1,145 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, type Page } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; -test("tag group selection and removal", async ({ page }) => { - await page.goto("http://127.0.0.1:8080/component/?name=tag_group&"); +const BASE = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:8080"; +const URL = `${BASE}/component/?name=tag_group&`; +const LOAD_TIMEOUT = 20 * 60 * 1000; - const bug = page.getByRole("row", { name: "bug" }); - const core = page.getByRole("row", { name: "core" }); - const feature = page.getByRole("row", { name: "feature" }); +function tag(page: Page, name: string) { + return page.getByRole("row", { name }); +} - await expect(bug).toHaveAttribute("data-selected", "true"); - await expect(core).toHaveAttribute("data-selected", "false"); +async function loadTagGroup(page: Page) { + await page.goto(URL, { timeout: LOAD_TIMEOUT }); + await expect(page.getByText("Labels", { exact: true })).toBeVisible({ + timeout: 30000, + }); + await expect(page.getByRole("grid")).toBeVisible(); +} - await core.click(); - await expect(core).toHaveAttribute("data-selected", "true"); +test.describe("Tag group", () => { + test.beforeEach(async ({ page }) => { + await loadTagGroup(page); + }); - await feature.click(); - await expect(feature).toHaveAttribute("data-selected", "false"); - await expect(feature).toHaveAttribute("data-disabled", "true"); + test.describe("Selection", () => { + test("shows initial selection and supports multiple selection", async ({ + page, + }) => { + const bug = tag(page, "bug"); + const core = tag(page, "core"); + const desktop = tag(page, "desktop"); - await page.getByRole("button", { name: "Remove item bug" }).click(); - await expect(bug).toHaveCount(0); + await expect(bug).toHaveAttribute("data-selected", "true"); + await expect(core).toHaveAttribute("data-selected", "false"); + + await core.click(); + await expect(bug).toHaveAttribute("data-selected", "true"); + await expect(core).toHaveAttribute("data-selected", "true"); + + await desktop.click(); + await expect(desktop).toHaveAttribute("data-selected", "true"); + }); + + test("does not clear the last selected tag when empty selection is disallowed", async ({ + page, + }) => { + const bug = tag(page, "bug"); + + await expect(bug).toHaveAttribute("data-selected", "true"); + await bug.click(); + await expect(bug).toHaveAttribute("data-selected", "true"); + + await tag(page, "core").click(); + await bug.click(); + await expect(bug).toHaveAttribute("data-selected", "false"); + await expect(tag(page, "core")).toHaveAttribute("data-selected", "true"); + }); + + test("ignores clicks on disabled tags", async ({ page }) => { + const feature = tag(page, "feature"); + const example = tag(page, "example"); + + await expect(feature).toHaveAttribute("data-disabled", "true"); + await expect(example).toHaveAttribute("data-disabled", "true"); + + await feature.click(); + await expect(feature).toHaveAttribute("data-selected", "false"); + + await example.click(); + await expect(example).toHaveAttribute("data-selected", "false"); + }); + + test("clears selection on Escape", async ({ page }) => { + const bug = tag(page, "bug"); + await bug.click(); + await expect(bug).toBeFocused(); + + await page.keyboard.press("Escape"); + await expect(bug).toHaveAttribute("data-selected", "false"); + await expect(tag(page, "core")).toHaveAttribute("data-selected", "false"); + }); + }); + + test.describe("Keyboard", () => { + test("roving focus skips disabled tags", async ({ page }) => { + const bug = tag(page, "bug"); + const core = tag(page, "core"); + + await bug.click(); + await expect(bug).toBeFocused(); + + await page.keyboard.press("ArrowRight"); + await expect(core).toBeFocused(); + + await page.keyboard.press("ArrowLeft"); + await expect(bug).toBeFocused(); + }); + + test("Space toggles selection on the focused tag", async ({ page }) => { + const core = tag(page, "core"); + + await core.click(); + await expect(core).toBeFocused(); + await expect(core).toHaveAttribute("data-selected", "true"); + + await page.keyboard.press("Space"); + await expect(core).toHaveAttribute("data-selected", "false"); + + await page.keyboard.press("Space"); + await expect(core).toHaveAttribute("data-selected", "true"); + }); + + test("Delete removes all selected tags", async ({ page }) => { + const bug = tag(page, "bug"); + const core = tag(page, "core"); + + await core.click(); + await expect(bug).toHaveAttribute("data-selected", "true"); + await expect(core).toHaveAttribute("data-selected", "true"); + + await core.click(); + await page.keyboard.press("Delete"); + + await expect(bug).toHaveCount(0); + await expect(core).toHaveCount(0); + }); + }); + + test.describe("Removal", () => { + test("remove button deletes a tag", async ({ page }) => { + const bug = tag(page, "bug"); + await expect(bug).toBeVisible(); + + await page.getByRole("button", { name: "Remove item bug" }).click(); + await expect(bug).toHaveCount(0); + }); + }); + + test.describe("Accessibility", () => { + test("has no automatically detectable a11y violations", async ({ page }) => { + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations).toEqual([]); + }); + }); }); From a034c04e1a0a7bef500a93ae7374cff7dea0a91d Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 16 May 2026 12:16:13 +0300 Subject: [PATCH 04/10] [*] Tag Group: fix tests --- playwright/tag_group.spec.ts | 23 +++++++++++++++-------- preview/src/components/mod.rs | 3 +-- primitives/src/lib.rs | 2 +- primitives/src/list_data.rs | 2 +- primitives/src/tag_group.rs | 2 +- 5 files changed, 19 insertions(+), 13 deletions(-) diff --git a/playwright/tag_group.spec.ts b/playwright/tag_group.spec.ts index eeeb8a0b3..865751d2d 100644 --- a/playwright/tag_group.spec.ts +++ b/playwright/tag_group.spec.ts @@ -18,6 +18,9 @@ async function loadTagGroup(page: Page) { } test.describe("Tag group", () => { + // One page load at a time — parallel navigations contend with the preview webServer build. + test.describe.configure({ mode: "serial" }); + test.beforeEach(async ({ page }) => { await loadTagGroup(page); }); @@ -56,17 +59,16 @@ test.describe("Tag group", () => { await expect(tag(page, "core")).toHaveAttribute("data-selected", "true"); }); - test("ignores clicks on disabled tags", async ({ page }) => { + test("marks disabled tags as non-interactive", async ({ page }) => { const feature = tag(page, "feature"); const example = tag(page, "example"); await expect(feature).toHaveAttribute("data-disabled", "true"); - await expect(example).toHaveAttribute("data-disabled", "true"); - - await feature.click(); + await expect(feature).toHaveAttribute("aria-disabled", "true"); await expect(feature).toHaveAttribute("data-selected", "false"); - await example.click(); + await expect(example).toHaveAttribute("data-disabled", "true"); + await expect(example).toHaveAttribute("aria-disabled", "true"); await expect(example).toHaveAttribute("data-selected", "false"); }); @@ -117,8 +119,8 @@ test.describe("Tag group", () => { await core.click(); await expect(bug).toHaveAttribute("data-selected", "true"); await expect(core).toHaveAttribute("data-selected", "true"); + await expect(core).toBeFocused(); - await core.click(); await page.keyboard.press("Delete"); await expect(bug).toHaveCount(0); @@ -137,8 +139,13 @@ test.describe("Tag group", () => { }); test.describe("Accessibility", () => { - test("has no automatically detectable a11y violations", async ({ page }) => { - const results = await new AxeBuilder({ page }).analyze(); + test("has no automatically detectable a11y violations on the tag list", async ({ + page, + }) => { + const results = await new AxeBuilder({ page }) + .include('[role="grid"]') + .disableRules(["color-contrast"]) + .analyze(); expect(results.violations).toEqual([]); }); }); diff --git a/preview/src/components/mod.rs b/preview/src/components/mod.rs index 3e337461b..071cba889 100644 --- a/preview/src/components/mod.rs +++ b/preview/src/components/mod.rs @@ -35,8 +35,7 @@ impl ComponentCategory { pub fn category_of(name: &str) -> ComponentCategory { match name { "button" | "input" | "textarea" | "label" | "checkbox" | "switch" | "radio_group" - | "toggle" | "toggle_group" | "select" | "slider" | "calendar" - | "date_picker" + | "toggle" | "toggle_group" | "select" | "slider" | "calendar" | "date_picker" | "color_picker" => ComponentCategory::Forms, "navbar" | "sidebar" | "tabs" | "pagination" | "menubar" | "toolbar" | "context_menu" | "dropdown_menu" => ComponentCategory::Navigation, diff --git a/primitives/src/lib.rs b/primitives/src/lib.rs index 41eda5c6f..bb40bb57b 100644 --- a/primitives/src/lib.rs +++ b/primitives/src/lib.rs @@ -49,10 +49,10 @@ pub mod separator; pub mod slider; pub mod switch; pub mod tabs; +pub mod tag_group; pub mod toast; pub mod toggle; pub mod toggle_group; -pub mod tag_group; pub mod toolbar; pub mod tooltip; pub(crate) mod r#virtual; diff --git a/primitives/src/list_data.rs b/primitives/src/list_data.rs index 99d1633df..56429d8fc 100644 --- a/primitives/src/list_data.rs +++ b/primitives/src/list_data.rs @@ -1076,7 +1076,7 @@ mod tests { dom_assert_eq!(item_keys(&list), vec!["a", "b", "c", "d", "e"]); list.move_after - .call(("b".into(), vec!["e".into(), "d".into()])); + .call(("с".into(), vec!["e".into(), "d".into()])); dom_assert_eq!(item_keys(&list), vec!["a", "b", "c", "d", "e"]); list.move_after diff --git a/primitives/src/tag_group.rs b/primitives/src/tag_group.rs index c430d219b..8e99e5a71 100644 --- a/primitives/src/tag_group.rs +++ b/primitives/src/tag_group.rs @@ -320,7 +320,7 @@ pub struct TagListProps { #[props(extends = GlobalAttributes)] pub attributes: Vec, - /// The children of the tag list component. Defaults to a [`Tag`] per item from [`TagGroup::items`]. + /// The children of the tag list component. Defaults to a [`Tag`] per item from [`TagGroupProps::items`]. #[props(default)] pub children: Option, } From 8c9e37341be8cd7c1b75d12214594fbcd8be11c3 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Tue, 19 May 2026 17:21:53 +0300 Subject: [PATCH 05/10] [*] Tag Group: as select/combobox components --- playwright/tag_group.spec.ts | 20 +- preview/src/components/mod.rs | 2 +- preview/src/components/tag_group/component.rs | 91 +- preview/src/components/tag_group/docs.md | 26 +- .../components/tag_group/variants/main/mod.rs | 35 +- .../tag_group/variants/multi/mod.rs | 31 + primitives/src/lib.rs | 1 - primitives/src/list_data.rs | 1205 ----------------- primitives/src/tag_group.rs | 969 ++++++++----- 9 files changed, 715 insertions(+), 1665 deletions(-) create mode 100644 preview/src/components/tag_group/variants/multi/mod.rs delete mode 100644 primitives/src/list_data.rs diff --git a/playwright/tag_group.spec.ts b/playwright/tag_group.spec.ts index 865751d2d..18835f190 100644 --- a/playwright/tag_group.spec.ts +++ b/playwright/tag_group.spec.ts @@ -5,16 +5,24 @@ const BASE = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:8080"; const URL = `${BASE}/component/?name=tag_group&`; const LOAD_TIMEOUT = 20 * 60 * 1000; +function multiVariant(page: Page) { + return page + .locator(".dx-component-variant") + .filter({ has: page.getByRole("heading", { name: "multi" }) }); +} + function tag(page: Page, name: string) { - return page.getByRole("row", { name }); + return multiVariant(page).getByRole("row", { name }); } async function loadTagGroup(page: Page) { await page.goto(URL, { timeout: LOAD_TIMEOUT }); - await expect(page.getByText("Labels", { exact: true })).toBeVisible({ + await expect( + multiVariant(page).getByText("Labels", { exact: true }), + ).toBeVisible({ timeout: 30000, }); - await expect(page.getByRole("grid")).toBeVisible(); + await expect(multiVariant(page).getByRole("grid")).toBeVisible(); } test.describe("Tag group", () => { @@ -133,7 +141,9 @@ test.describe("Tag group", () => { const bug = tag(page, "bug"); await expect(bug).toBeVisible(); - await page.getByRole("button", { name: "Remove item bug" }).click(); + await multiVariant(page) + .getByRole("button", { name: "Remove item bug" }) + .click(); await expect(bug).toHaveCount(0); }); }); @@ -143,7 +153,7 @@ test.describe("Tag group", () => { page, }) => { const results = await new AxeBuilder({ page }) - .include('[role="grid"]') + .include(".dx-component-variant [role=\"grid\"]") .disableRules(["color-contrast"]) .analyze(); expect(results.violations).toEqual([]); diff --git a/preview/src/components/mod.rs b/preview/src/components/mod.rs index 071cba889..345284f65 100644 --- a/preview/src/components/mod.rs +++ b/preview/src/components/mod.rs @@ -206,7 +206,7 @@ examples!( slider[dynamic_range, range], switch, tabs, - tag_group, + tag_group[multi], textarea[outline, fade, ghost], toast, toggle, diff --git a/preview/src/components/tag_group/component.rs b/preview/src/components/tag_group/component.rs index 1faa3d25c..72a18becb 100644 --- a/preview/src/components/tag_group/component.rs +++ b/preview/src/components/tag_group/component.rs @@ -1,91 +1,104 @@ use dioxus::prelude::*; use dioxus_icons::lucide::X; -use dioxus_primitives::tag_group::{self, TagGroupCtx, TagGroupProps, TagListProps, TagProps}; -use std::collections::HashSet; +use dioxus_primitives::tag_group::{ + self, TagGroupMultiProps, TagGroupProps, TagListProps, TagOptionProps, +}; #[css_module("/src/components/tag_group/style.css")] struct Styles; #[component] -pub fn TagGroup(props: TagGroupProps) -> Element { +pub fn TagGroup(props: TagGroupProps) -> Element { rsx! { tag_group::TagGroup { class: Styles::dx_tag_group, label: props.label, - items: props.items, - selection_mode: props.selection_mode, - selected_tags: props.selected_tags, - default_selected_tags: props.default_selected_tags, - on_selection_change: props.on_selection_change, - disabled_tags: props.disabled_tags, + value: props.value, + default_value: props.default_value, + on_value_change: props.on_value_change, disabled: props.disabled, - allows_empty_selection: props.allows_empty_selection, + selectable: props.selectable, + disabled_values: props.disabled_values, + allow_empty_selection: props.allow_empty_selection, escape_clears_selection: props.escape_clears_selection, allows_removing: props.allows_removing, roving_loop: props.roving_loop, render_empty_state: props.render_empty_state, attributes: props.attributes, - TagList { {props.children} } + TagList { + {props.children} + } } } } #[component] -fn TagList(props: TagListProps) -> Element { - let ctx: TagGroupCtx = use_context(); - let is_removable = ctx.is_removable(); +pub fn TagGroupMulti(props: TagGroupMultiProps) -> Element { + rsx! { + tag_group::TagGroupMulti { + class: Styles::dx_tag_group, + label: props.label, + values: props.values, + default_values: props.default_values, + on_values_change: props.on_values_change, + disabled: props.disabled, + selectable: props.selectable, + disabled_values: props.disabled_values, + allow_empty_selection: props.allow_empty_selection, + escape_clears_selection: props.escape_clears_selection, + allows_removing: props.allows_removing, + roving_loop: props.roving_loop, + render_empty_state: props.render_empty_state, + attributes: props.attributes, + TagList { + {props.children} + } + } + } +} +#[component] +pub fn TagList(props: TagListProps) -> Element { rsx! { tag_group::TagList { class: Styles::dx_tag_list, attributes: props.attributes, - for item in tag_group::use_tag_list_items() { - Tag { - key: "{item.key}", - index: item.index, - {item.children} - if is_removable { - RemoveButton { index: item.index } - } - } - } {props.children} } } } #[component] -pub fn Tag(props: TagProps) -> Element { +pub fn Tag(props: TagOptionProps) -> Element { + let ctx = use_context::(); + let is_removable = ctx.is_removable(); + rsx! { - tag_group::Tag { + tag_group::TagOption:: { class: Styles::dx_tag, - index: props.index, + value: props.value, + text_value: props.text_value, disabled: props.disabled, + id: props.id, + index: props.index, attributes: props.attributes, {props.children} + if is_removable { + RemoveButton {} + } } } } #[component] fn RemoveButton( - index: usize, #[props(extends = GlobalAttributes)] attributes: Vec, children: Element, ) -> Element { - let mut ctx: TagGroupCtx = use_context(); - let tag_key = ctx.item_key(index); - rsx! { - button { + tag_group::TagRemoveButton { class: Styles::dx_remove_button, - r#type: "button", - aria_label: format!("Remove item {tag_key}"), - onclick: move |e| { - e.stop_propagation(); - ctx.remove_tags(HashSet::from([tag_key.clone()])); - }, - ..attributes, + attributes, {children} X { size: "12px" } } diff --git a/preview/src/components/tag_group/docs.md b/preview/src/components/tag_group/docs.md index 7e9dc65eb..20ebe02fa 100644 --- a/preview/src/components/tag_group/docs.md +++ b/preview/src/components/tag_group/docs.md @@ -4,25 +4,17 @@ A Tag Group is a focusable group of tags (labels, categories, filters and simila ## Structure +Single selection with [`TagGroup`](component.rs): + ```rust TagGroup { - // Optional visible label for the group - label, - items, - // The type of selection that is allowed in the group. - selection_mode, - // Controlled selection (keys match item `key` values) - selected_tags, - on_selection_change: move |tags| { /* ... */ }, - // Keys that cannot be selected or focused - disabled_tags, - // Show remove buttons; Delete/Backspace removes selected tags - allows_removing, - // Shown when `items` is empty - render_empty_state, + label: "Labels", + value: Some(value.into()), + on_value_change: move |value| { /* ... */ }, + allows_removing: true, + Tag { index: 0usize, value: "bug", "bug" } + Tag { index: 1usize, value: "feature", disabled: true, "feature" } } ``` -## Item keys - -Each entry in `items` should set a vnode `key`. Keys are used for selection, disabled state, and removal. If `key` is omitted, the item index is used as a string. +Multiple selection with [`TagGroupMulti`](component.rs) — see the **multi** variant demo. diff --git a/preview/src/components/tag_group/variants/main/mod.rs b/preview/src/components/tag_group/variants/main/mod.rs index d2f3b5a6f..ab5480f6f 100644 --- a/preview/src/components/tag_group/variants/main/mod.rs +++ b/preview/src/components/tag_group/variants/main/mod.rs @@ -1,39 +1,30 @@ use dioxus::prelude::*; -use dioxus_primitives::tag_group::SelectionMode; use super::super::component::*; -use std::collections::HashSet; #[component] pub fn Demo() -> Element { - let items = [ - ("bug", "bug"), - ("feature", "feature"), - ("core", "core"), - ("desktop", "desktop"), - ("example", "example"), - ("duplicate", "duplicate"), - ] - .map(|(key, label)| { + let labels = ["bug", "feature", "core", "desktop", "example", "duplicate"]; + let tags = labels.iter().enumerate().map(|(index, &t)| { rsx! { - span { key: "{key}", "{label}" } + Tag { + index, + value: t, + "{t}" + } } - }) - .to_vec(); + }); - let mut selected = use_signal(|| HashSet::from(["bug".into()])); - let selected_tags = use_memo(move || Some(selected())); + let mut value = use_signal(|| Some("core".to_string())); rsx! { TagGroup { label: "Labels", - items, - selection_mode: SelectionMode::Multiple, - disabled_tags: HashSet::from(["feature".into(), "example".into()]), - selected_tags, - on_selection_change: move |tags| selected.set(tags), - allows_empty_selection: false, + value: Some(value.into()), + on_value_change: move |v| value.set(v), + allow_empty_selection: false, allows_removing: true, + {tags} } } } diff --git a/preview/src/components/tag_group/variants/multi/mod.rs b/preview/src/components/tag_group/variants/multi/mod.rs new file mode 100644 index 000000000..b724d1163 --- /dev/null +++ b/preview/src/components/tag_group/variants/multi/mod.rs @@ -0,0 +1,31 @@ +use dioxus::prelude::*; + +use super::super::component::*; + +#[component] +pub fn Demo() -> Element { + let labels = ["bug", "feature", "core", "desktop", "example", "duplicate"]; + let tags = labels.iter().enumerate().map(|(index, &t)| { + rsx! { + Tag { + index, + value: t, + "{t}" + } + } + }); + + let mut values = use_signal(|| vec!["bug".to_string()]); + let values_signal = use_memo(move || Some(values())); + + rsx! { + TagGroupMulti { + label: "Labels", + values: values_signal, + on_values_change: move |v| values.set(v), + allow_empty_selection: false, + allows_removing: true, + {tags} + } + } +} diff --git a/primitives/src/lib.rs b/primitives/src/lib.rs index bb40bb57b..ac565bbc3 100644 --- a/primitives/src/lib.rs +++ b/primitives/src/lib.rs @@ -30,7 +30,6 @@ pub mod dropdown_menu; mod focus; pub mod hover_card; pub mod label; -pub mod list_data; mod listbox; pub mod menubar; mod move_interaction; diff --git a/primitives/src/list_data.rs b/primitives/src/list_data.rs deleted file mode 100644 index 56429d8fc..000000000 --- a/primitives/src/list_data.rs +++ /dev/null @@ -1,1205 +0,0 @@ -//! List state for dynamic collections: items, selection, filtering, and list mutations. -//! -//! [`use_list_data`] keeps a list of items of type `T`, optional multi-select, and optional -//! filter text. Mutations (`append`, `remove`, `move_before`, …) update internal state; consumers -//! read the current collection from [`ListData::items`]. -//! -//! # Item keys -//! -//! Each row needs a stable string key for selection, removal, and reordering. Keys come from the -//! `key` attribute on the [`Element`] returned by [`ListOptions::to_element`] (see [`element_key`]). -//! If no key is set, the item index is used as a string. -//! -//! ```rust -//! use dioxus::prelude::*; -//! use dioxus_primitives::list_data::{use_list_data, ListOptions}; -//! use std::rc::Rc; -//! -//! #[derive(Clone, PartialEq)] -//! struct Row { id: u32, label: String } -//! -//! #[component] -//! fn Example() -> Element { -//! let list = use_list_data(ListOptions { -//! initial_items: vec![Row { id: 1, label: "News".into() }], -//! to_element: Rc::new(|row| rsx! { -//! span { key: "{row.id}", {row.label.clone()} } -//! }), -//! ..Default::default() -//! }); -//! -//! rsx! { -//! for (index, row) in (list.items)().into_iter().enumerate() { -//! div { key: "{list.item_key(index)}", {row.label} } -//! } -//! } -//! } -//! ``` -//! -//! # Selection -//! -//! Selection is controlled or uncontrolled via [`ListOptions::selected_keys`], -//! [`ListOptions::default_selected_keys`], and [`ListOptions::on_selected_keys_change`]. -//! Use [`ListSelection::All`] or [`ListSelection::Keys`] for the current value. -//! -//! # Filtering -//! -//! When [`ListOptions::filter`] is set, [`ListData::items`] returns only items that match -//! [`ListData::filter_text`]. The full list remains in internal state; filtered rows are a view. -//! -//! # Wiring to UI -//! -//! Call [`use_list_data`] in the parent component, read [`ListData::items`] in `rsx!`, and use -//! callbacks such as [`ListData::remove`] and [`ListData::append`] to update the list. - -use dioxus::prelude::*; -use std::collections::HashSet; -use std::rc::Rc; - -use crate::use_controlled; - -/// Returns the `key` attribute value for `element`, or `index` when unset. -/// -/// Used by [`use_list_data`] for stable row identity. -pub fn element_key(element: &Element, index: usize) -> String { - element - .as_ref() - .ok() - .and_then(|vnode| vnode.key.clone()) - .unwrap_or_else(|| index.to_string()) -} - -/// Stable string key for a row in the list. -pub type ListKey = String; - -/// Converts a list item to an [`Element`] (must set a `key` attribute for stable identity). -pub type ToElementFn = Rc Element>; - -/// Returns whether an item matches the current filter text. -pub type ListFilterFn = Rc bool>; - -/// Updates a list item from its previous value (used by [`UpdateValue::Map`]). -pub type ListItemUpdateFn = Rc T>; - -/// Either every item is selected or an explicit key set. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ListSelection { - /// All items are selected. - All, - /// Selected keys only. - Keys(HashSet), -} - -#[derive(Debug, Clone, PartialEq)] -struct ListState { - items: Vec, -} - -/// Options for [`use_list_data`]. -pub struct ListOptions { - /// Initial items in the list. - pub initial_items: Vec, - /// Renders an item as an [`Element`] with a `key` attribute value (see [`element_key`]). - pub to_element: ToElementFn, - /// The currently selected keys. `None` means uncontrolled. - pub selected_keys: ReadSignal>, - /// The default selected keys when uncontrolled. - pub default_selected_keys: ListSelection, - /// Called when the selection changes. - pub on_selected_keys_change: Callback, - /// The current filter text. `None` means uncontrolled. - pub filter_text: ReadSignal>, - /// The default filter text when uncontrolled. - pub default_filter_text: String, - /// Called when the filter text changes. - pub on_filter_text_change: Callback, - /// A function that returns whether a item matches the current filter text. - pub filter: Option>, -} - -impl Default for ListOptions { - fn default() -> Self { - Self { - initial_items: Vec::new(), - to_element: Rc::new(|_| rsx! { span {} }), - selected_keys: ReadSignal::new(Signal::new(None)), - default_selected_keys: ListSelection::Keys(HashSet::new()), - on_selected_keys_change: Callback::default(), - filter_text: ReadSignal::new(Signal::new(None)), - default_filter_text: String::new(), - on_filter_text_change: Callback::default(), - filter: None, - } - } -} - -/// New value for [`ListData::update`]. -#[derive(Clone)] -pub enum UpdateValue { - /// Replace the item. - Replace(T), - /// Update from the previous value. - Map(ListItemUpdateFn), -} - -/// Handle returned by [`use_list_data`]. Clone to share the same list state across components. -pub struct ListData { - to_element: ToElementFn, - /// The items in the list (filtered when [`ListOptions::filter`] is set). - pub items: Memo>, - /// The keys of the currently selected items in the list. - pub selected_keys: Memo, - /// Sets the selected keys. - pub set_selected_keys: Callback, - /// Adds the given keys to the current selected keys; pass [`ListSelection::All`] to select all keys. - pub add_keys_to_selection: Callback, - /// Removes the given keys from the current selected keys; [`ListSelection::All`] clears to an empty key set. - pub remove_keys_from_selection: Callback, - /// The current filter text. - pub filter_text: Memo, - /// Sets the filter text (used with [`ListOptions::filter`]). - pub set_filter_text: Callback, - /// Gets an item from the list by key. - pub get_item: Callback>, - /// Inserts items into the list at the given `index` (clamped to end). - pub insert: Callback<(usize, Vec)>, - /// Inserts items into the list before the item at the given `key` (or at start if the list is empty). - pub insert_before: Callback<(ListKey, Vec)>, - /// Inserts items into the list after the item at the given `key`. - pub insert_after: Callback<(ListKey, Vec)>, - /// Appends items to the list. - pub append: Callback>, - /// Prepends items to the list. - pub prepend: Callback>, - /// Removes items from the list by their keys. - pub remove: Callback>, - /// Removes all items from the list that are currently in the set of selected items. - pub remove_selected_items: Callback<()>, - /// Moves an item within the list. - pub r#move: Callback<(ListKey, usize)>, - /// Moves one or more items before a given `key`. - pub move_before: Callback<(ListKey, Vec)>, - /// Moves one or more items after a given `key`. - pub move_after: Callback<(ListKey, Vec)>, - /// Updates an item in the list. - pub update: Callback<(ListKey, UpdateValue)>, -} - -impl ListData { - /// Returns the stable key for the item at `index` (vnode `key`, or `index` as string). - pub fn item_key(&self, index: usize) -> String { - (self.items)() - .get(index) - .map(|item| item_key(item, self.to_element.as_ref(), index)) - .unwrap_or_else(|| index.to_string()) - } -} - -fn item_key(item: &T, to_element: &dyn Fn(&T) -> Element, index: usize) -> String { - element_key(&to_element(item), index) -} - -fn all_keys(items: &[T], to_element: &dyn Fn(&T) -> Element) -> HashSet { - items - .iter() - .enumerate() - .map(|(index, item)| item_key(item, to_element, index)) - .collect() -} - -fn find_index(items: &[T], to_element: &dyn Fn(&T) -> Element, key: &str) -> Option { - items - .iter() - .enumerate() - .find(|(index, item)| item_key(*item, to_element, *index) == key) - .map(|(index, _)| index) -} - -/// Move indices `indices` (sorted ascending) to bucket starting at `to_index` -fn move_indices( - mut items: Vec, - mut indices: Vec, - mut to_index: usize, -) -> Vec { - if indices.is_empty() { - return items; - } - indices.sort_unstable(); - to_index -= indices.iter().filter(|&&i| i < to_index).count(); - - let mut moves: Vec<(usize, usize)> = indices - .into_iter() - .enumerate() - .map(|(k, from)| (from, to_index + k)) - .collect(); - - for i in 0..moves.len() { - let a = moves[i].0; - for slot in moves.iter_mut().skip(i) { - if slot.0 > a { - slot.0 -= 1; - } - } - } - - for i in 0..moves.len() { - for j in (i + 1..moves.len()).rev() { - if moves[j].0 < moves[i].1 { - moves[i].1 += 1; - } else { - moves[j].0 += 1; - } - } - } - - for (from, to) in moves { - if from >= items.len() { - continue; - } - let item = items.remove(from); - let to = to.min(items.len()); - items.insert(to, item); - } - - items -} - -/// Creates list state for a dynamic collection. -/// -/// Must be called at the top level of a component (like any Dioxus hook). Returns [`ListData`] -/// with reactive [`ListData::items`], selection, filter text, and callbacks to mutate the list. -pub fn use_list_data(options: ListOptions) -> ListData { - let to_element = options.to_element.clone(); - let filter = options.filter.clone(); - - let state = use_signal(move || ListState { - items: options.initial_items, - }); - - let (selected_keys, set_selected_keys) = use_controlled( - options.selected_keys, - options.default_selected_keys, - options.on_selected_keys_change, - ); - - let (filter_text, set_filter_text) = use_controlled( - options.filter_text, - options.default_filter_text, - options.on_filter_text_change, - ); - - let items = use_memo({ - let filter = filter.clone(); - move || { - let s = state.read(); - match &filter { - Some(f) => s - .items - .iter() - .filter(|item| f(item, filter_text().as_str())) - .cloned() - .collect(), - None => s.items.clone(), - } - } - }); - - let add_keys_to_selection = use_callback({ - let to_element = to_element.clone(); - move |incoming: ListSelection| { - let valid_keys = all_keys(&state.read().items, to_element.as_ref()); - match selected_keys() { - ListSelection::All => (), - ListSelection::Keys(cur) => match incoming { - ListSelection::All => set_selected_keys.call(ListSelection::All), - ListSelection::Keys(extra) => { - let mut next = cur; - for k in extra { - if valid_keys.contains(&k) { - next.insert(k); - } - } - set_selected_keys.call(ListSelection::Keys(next)); - } - }, - } - } - }); - - let remove_keys_from_selection = use_callback({ - let to_element = to_element.clone(); - move |incoming: ListSelection| match incoming { - ListSelection::All => { - set_selected_keys.call(ListSelection::Keys(HashSet::new())); - } - ListSelection::Keys(to_remove) => { - let items = state.read().items.clone(); - let mut all = match selected_keys() { - ListSelection::All => all_keys(&items, to_element.as_ref()), - ListSelection::Keys(cur) => cur, - }; - for k in to_remove { - all.remove(&k); - } - set_selected_keys.call(ListSelection::Keys(all)); - } - } - }); - - let get_item = use_callback({ - let to_element = to_element.clone(); - move |key: ListKey| { - let s = state.read(); - let index = find_index(&s.items, to_element.as_ref(), &key)?; - s.items.get(index).cloned() - } - }); - - let insert = use_callback({ - let mut state = state; - move |(index, values): (usize, Vec)| { - let mut w = state.write(); - let idx = index.min(w.items.len()); - w.items.splice(idx..idx, values); - } - }); - - let insert_before = use_callback({ - let mut state = state; - let to_element = to_element.clone(); - move |(key, values): (ListKey, Vec)| { - let mut w = state.write(); - let idx = match find_index(&w.items, to_element.as_ref(), &key) { - Some(i) => i, - None if w.items.is_empty() => 0, - None => return, - }; - w.items.splice(idx..idx, values); - } - }); - - let insert_after = use_callback({ - let mut state = state; - let to_element = to_element.clone(); - move |(key, values): (ListKey, Vec)| { - let mut w = state.write(); - let idx = match find_index(&w.items, to_element.as_ref(), &key) { - Some(i) => i + 1, - None if w.items.is_empty() => 0, - None => return, - }; - w.items.splice(idx..idx, values); - } - }); - - let append = use_callback({ - let mut state = state; - move |values: Vec| { - let mut w = state.write(); - let len = w.items.len(); - w.items.splice(len..len, values); - } - }); - - let prepend = use_callback({ - let mut state = state; - move |values: Vec| { - let mut w = state.write(); - w.items.splice(0..0, values); - } - }); - - let remove = use_callback({ - let mut state = state; - let to_element = to_element.clone(); - move |keys: HashSet| { - let mut w = state.write(); - let keys_prune = keys.clone(); - let sel = selected_keys(); - let mut indices: Vec = w - .items - .iter() - .enumerate() - .filter_map(|(index, item)| { - keys.contains(&item_key(item, to_element.as_ref(), index)) - .then_some(index) - }) - .collect(); - indices.sort_unstable_by(|a, b| b.cmp(a)); - for index in indices { - let _ = w.items.remove(index); - } - - let next = if w.items.is_empty() { - ListSelection::Keys(HashSet::new()) - } else { - match sel { - ListSelection::All => ListSelection::All, - ListSelection::Keys(mut cur) => { - for k in keys_prune { - cur.remove(&k); - } - ListSelection::Keys(cur) - } - } - }; - set_selected_keys.call(next); - } - }); - - let remove_selected_items = use_callback({ - let mut state = state; - let to_element = to_element.clone(); - move |_| { - let mut w = state.write(); - match selected_keys() { - ListSelection::All => { - w.items.clear(); - } - ListSelection::Keys(sel) => { - let mut indices: Vec = w - .items - .iter() - .enumerate() - .filter_map(|(index, item)| { - sel.contains(&item_key(item, to_element.as_ref(), index)) - .then_some(index) - }) - .collect(); - indices.sort_unstable_by(|a, b| b.cmp(a)); - for index in indices { - w.items.remove(index); - } - } - } - set_selected_keys.call(ListSelection::Keys(HashSet::new())); - } - }); - - let move_one = use_callback({ - let mut state = state; - let to_element = to_element.clone(); - move |(key, to_index): (ListKey, usize)| { - let mut w = state.write(); - let Some(from) = find_index(&w.items, to_element.as_ref(), &key) else { - return; - }; - let mut items = std::mem::take(&mut w.items); - let item = items.remove(from); - let at = to_index.min(items.len()); - items.insert(at, item); - w.items = items; - } - }); - - let move_before = use_callback({ - let mut state = state; - let to_element = to_element.clone(); - move |(anchor_key, keys): (ListKey, Vec)| { - let mut w = state.write(); - let Some(to_index) = find_index(&w.items, to_element.as_ref(), &anchor_key) else { - return; - }; - let mut indices: Vec = keys - .iter() - .filter_map(|k| find_index(&w.items, to_element.as_ref(), k)) - .collect(); - indices.sort_unstable(); - w.items = move_indices(std::mem::take(&mut w.items), indices, to_index); - } - }); - - let move_after = use_callback({ - let mut state = state; - let to_element = to_element.clone(); - move |(anchor_key, keys): (ListKey, Vec)| { - let mut w = state.write(); - let Some(idx) = find_index(&w.items, to_element.as_ref(), &anchor_key) else { - return; - }; - let to_index = idx + 1; - let mut indices: Vec = keys - .iter() - .filter_map(|k| find_index(&w.items, to_element.as_ref(), k)) - .collect(); - indices.sort_unstable(); - w.items = move_indices(std::mem::take(&mut w.items), indices, to_index); - } - }); - - let update = use_callback({ - let mut state = state; - let to_element = to_element.clone(); - move |(key, new_value): (ListKey, UpdateValue)| { - let mut w = state.write(); - let Some(i) = find_index(&w.items, to_element.as_ref(), &key) else { - return; - }; - let updated = match new_value { - UpdateValue::Replace(v) => v, - UpdateValue::Map(f) => f(w.items[i].clone()), - }; - w.items[i] = updated; - } - }); - - ListData { - to_element, - items, - selected_keys, - filter_text, - set_selected_keys, - add_keys_to_selection, - remove_keys_from_selection, - set_filter_text, - get_item, - insert, - insert_before, - insert_after, - append, - prepend, - remove, - remove_selected_items, - r#move: move_one, - move_before, - move_after, - update, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::cell::RefCell; - use std::collections::HashSet; - use std::panic::{catch_unwind, resume_unwind, AssertUnwindSafe}; - use std::rc::Rc; - - thread_local! { - static DOM_TEST_ERROR: RefCell> = const { RefCell::new(None) }; - } - - fn dom_test_fail(message: impl Into) { - DOM_TEST_ERROR.with(|slot| { - if slot.borrow().is_none() { - *slot.borrow_mut() = Some(message.into()); - } - }); - } - - fn dom_test_finish() { - DOM_TEST_ERROR.with(|slot| { - if let Some(message) = slot.borrow_mut().take() { - panic!("{message}"); - } - }); - } - - /// Like [`assert!`] inside `run_dom!` — records failure for the outer `#[test]`. - macro_rules! dom_assert { - ($cond:expr $(,)?) => { - if !$cond { - dom_test_fail(format!( - "assertion failed: {}\n at {}:{}", - stringify!($cond), - file!(), - line!(), - )); - } - }; - ($cond:expr, $($arg:tt)+) => { - if !$cond { - dom_test_fail(format!( - "assertion failed: {}\n at {}:{}", - format_args!($($arg)+), - file!(), - line!(), - )); - } - }; - } - - /// Like [`assert_eq!`] inside `run_dom!` — records failure for the outer `#[test]`. - macro_rules! dom_assert_eq { - ($left:expr, $right:expr $(,)?) => { - if $left != $right { - dom_test_fail(format!( - "assertion `left == right` failed\n left: {:?}\n right: {:?}\n at {}:{}", - $left, - $right, - file!(), - line!(), - )); - } - }; - } - - /// Run a `#[component]` that performs assertions during its first render. - macro_rules! run_dom { - ($component:path) => {{ - DOM_TEST_ERROR.with(|slot| *slot.borrow_mut() = None); - let result = catch_unwind(AssertUnwindSafe(|| { - let mut dom = VirtualDom::new($component); - dom.rebuild_in_place(); - })); - match result { - Ok(()) => dom_test_finish(), - Err(payload) => resume_unwind(payload), - } - }}; - } - - #[derive(Clone, PartialEq, Debug)] - struct Row { - id: String, - n: i32, - } - - fn row(id: &str, n: i32) -> Row { - Row { - id: id.to_string(), - n, - } - } - - fn row_to_element(row: &Row) -> Element { - rsx! { - span { key: "{row.id}", "{row.id}" } - } - } - - fn item_keys(list: &ListData) -> Vec { - let items = (list.items)(); - (0..items.len()).map(|index| list.item_key(index)).collect() - } - - fn test_options( - initial_items: Vec, - default_selected_keys: ListSelection, - filter: Option>, - ) -> ListOptions { - ListOptions { - initial_items, - to_element: Rc::new(row_to_element), - default_selected_keys, - filter, - ..Default::default() - } - } - - fn empty_keys() -> ListSelection { - ListSelection::Keys(HashSet::new()) - } - - #[component] - fn empty_data() -> Element { - let list = use_list_data(test_options(vec![], empty_keys(), None)); - dom_assert!((list.items)().is_empty()); - dom_assert!(matches!( - (list.selected_keys)(), - ListSelection::Keys(k) if k.is_empty() - )); - dom_assert_eq!((list.filter_text)(), ""); - rsx! { - div {} - } - } - - #[component] - fn no_selection() -> Element { - let list = use_list_data(test_options( - vec![row("a", 1), row("b", 2), row("c", 3)], - ListSelection::Keys(HashSet::new()), - None, - )); - dom_assert_eq!(item_keys(&list), vec!["a", "b", "c"]); - match (list.selected_keys)() { - ListSelection::Keys(k) => dom_assert!(k.is_empty()), - ListSelection::All => panic!("expected Keys"), - } - rsx! { - div {} - } - } - - #[component] - fn partial_selection() -> Element { - let list = use_list_data(test_options( - vec![row("a", 1), row("b", 2), row("c", 3)], - ListSelection::Keys(HashSet::from(["b".into()])), - None, - )); - dom_assert_eq!(item_keys(&list), vec!["a", "b", "c"]); - match (list.selected_keys)() { - ListSelection::Keys(k) => { - dom_assert_eq!(k.len(), 1); - dom_assert!(k.contains("b")); - } - ListSelection::All => panic!("expected Keys"), - } - rsx! { - div {} - } - } - - #[component] - fn selected_all() -> Element { - let list = use_list_data(test_options( - vec![row("a", 1), row("b", 2), row("c", 3)], - ListSelection::All, - None, - )); - dom_assert_eq!(item_keys(&list), vec!["a", "b", "c"]); - match (list.selected_keys)() { - ListSelection::Keys(_) => panic!("expected All"), - ListSelection::All => (), - } - rsx! { - div {} - } - } - - #[component] - fn update_selection() -> Element { - let list = use_list_data(test_options( - vec![row("a", 1), row("b", 2), row("c", 3)], - ListSelection::Keys(HashSet::new()), - None, - )); - - dom_assert_eq!(item_keys(&list), vec!["a", "b", "c"]); - match (list.selected_keys)() { - ListSelection::Keys(k) => dom_assert!(k.is_empty()), - ListSelection::All => panic!("expected Keys"), - } - - list.set_selected_keys.call(ListSelection::All); - match (list.selected_keys)() { - ListSelection::Keys(_) => panic!("expected All"), - ListSelection::All => (), - }; - - list.remove_keys_from_selection - .call(ListSelection::Keys(HashSet::from(["z".into()]))); - match (list.selected_keys)() { - ListSelection::Keys(k) => { - dom_assert_eq!(k.len(), 3); - dom_assert!(k.contains("b")); - } - ListSelection::All => panic!("expected Keys"), - }; - - list.remove_keys_from_selection - .call(ListSelection::Keys(HashSet::from(["b".into()]))); - match (list.selected_keys)() { - ListSelection::Keys(k) => { - dom_assert_eq!(k.len(), 2); - dom_assert!(k.contains("a")); - dom_assert!(k.contains("c")); - } - ListSelection::All => panic!("expected Keys"), - }; - - rsx! { - div {} - } - } - - #[component] - fn append_prepend_insert() -> Element { - let list = use_list_data(test_options(vec![], empty_keys(), None)); - list.insert_before - .call(("missing".into(), vec![row("m", 1)])); - dom_assert_eq!(item_keys(&list), vec!["m"]); - - list.append.call(vec![row("a", 1)]); - dom_assert_eq!(item_keys(&list), vec!["m", "a"]); - - list.prepend.call(vec![row("z", 9)]); - dom_assert_eq!(item_keys(&list), vec!["z", "m", "a"]); - - list.insert.call((1, vec![row("x", 5)])); - dom_assert_eq!(item_keys(&list), vec!["z", "x", "m", "a"]); - - list.insert.call((10, vec![row("v", 5)])); - dom_assert_eq!(item_keys(&list), vec!["z", "x", "m", "a", "v"]); - - list.insert_before - .call(("m".into(), vec![row("before_m", -1)])); - dom_assert_eq!(item_keys(&list), vec!["z", "x", "before_m", "m", "a", "v"]); - - list.insert_before - .call(("m2".into(), vec![row("failed", -1)])); - dom_assert_eq!(item_keys(&list), vec!["z", "x", "before_m", "m", "a", "v"]); - - list.insert_after - .call(("m".into(), vec![row("after_m", 2)])); - dom_assert_eq!( - item_keys(&list), - vec!["z", "x", "before_m", "m", "after_m", "a", "v"] - ); - - list.insert_after - .call(("m2".into(), vec![row("failed", -1)])); - dom_assert_eq!( - item_keys(&list), - vec!["z", "x", "before_m", "m", "after_m", "a", "v"] - ); - - rsx! { - div {} - } - } - - #[component] - fn remove_and_get_item() -> Element { - let list = use_list_data(test_options( - vec![row("a", 1), row("b", 2), row("c", 3)], - empty_keys(), - None, - )); - dom_assert_eq!(list.get_item.call("b".into()), Some(row("b", 2))); - dom_assert_eq!(list.get_item.call("z".into()), None::); - - list.remove.call(HashSet::from(["b".into(), "c".into()])); - dom_assert_eq!(item_keys(&list), vec!["a"]); - - list.remove.call(HashSet::from(["z".into()])); - dom_assert_eq!(item_keys(&list), vec!["a"]); - - list.add_keys_to_selection.call(ListSelection::All); - list.remove.call(HashSet::from(["a".into()])); - dom_assert!((list.items)().is_empty()); - dom_assert!(matches!( - (list.selected_keys)(), - ListSelection::Keys(k) if k.is_empty() - )); - - rsx! { - div {} - } - } - - #[component] - fn selection_set_add_remove() -> Element { - let list = use_list_data(test_options( - vec![row("a", 1), row("b", 2)], - ListSelection::Keys(HashSet::from(["a".into()])), - None, - )); - - match (list.selected_keys)() { - ListSelection::Keys(k) => { - dom_assert!(k.len() == 1 && k.contains("a")); - } - _ => panic!("expected Keys"), - } - - list.add_keys_to_selection - .call(ListSelection::Keys(HashSet::from(["b".into()]))); - match (list.selected_keys)() { - ListSelection::Keys(k) => { - dom_assert!(k.contains("a") && k.contains("b")); - } - _ => panic!("expected Keys"), - } - - list.add_keys_to_selection - .call(ListSelection::Keys(HashSet::from(["c".into(), "z".into()]))); - match (list.selected_keys)() { - ListSelection::Keys(k) => dom_assert_eq!(k.len(), 2), - _ => panic!("expected Keys"), - } - - list.add_keys_to_selection.call(ListSelection::All); - dom_assert!(matches!((list.selected_keys)(), ListSelection::All)); - - list.remove_keys_from_selection - .call(ListSelection::Keys(HashSet::from(["a".into()]))); - match (list.selected_keys)() { - ListSelection::Keys(k) => dom_assert!(!k.contains("a")), - _ => panic!("expected Keys after remove subset from All expansion"), - } - - list.set_selected_keys - .call(ListSelection::Keys(HashSet::from(["b".into()]))); - match (list.selected_keys)() { - ListSelection::Keys(k) => { - dom_assert_eq!(k.len(), 1); - dom_assert!(k.contains("b")); - } - _ => panic!("expected Keys"), - } - - list.set_selected_keys - .call(ListSelection::Keys(HashSet::from(["v".into()]))); - match (list.selected_keys)() { - ListSelection::Keys(k) => dom_assert_eq!(k.len(), 1), - _ => panic!("expected Keys"), - } - - list.remove_keys_from_selection.call(ListSelection::All); - match (list.selected_keys)() { - ListSelection::Keys(k) => dom_assert!(k.is_empty()), - _ => panic!("expected empty Keys"), - } - rsx! { - div {} - } - } - - #[component] - fn filter_and_filter_text() -> Element { - let list = use_list_data(test_options( - vec![row("apple", 1), row("banana", 2), row("apricot", 3)], - empty_keys(), - Some(Rc::new(|r: &Row, q: &str| { - r.id.to_lowercase().contains(&q.to_lowercase()) - })), - )); - dom_assert_eq!((list.items)().len(), 3); - - list.set_filter_text.call("ap".into()); - dom_assert_eq!((list.filter_text)(), "ap"); - dom_assert_eq!((list.items)().len(), 2); - - list.set_filter_text.call(String::new()); - dom_assert_eq!((list.filter_text)(), ""); - dom_assert_eq!((list.items)().len(), 3); - - list.set_filter_text.call("42".into()); - dom_assert_eq!((list.filter_text)(), "42"); - dom_assert!((list.items)().is_empty()); - - rsx! { - div {} - } - } - - #[component] - fn remove_selected_items() -> Element { - let list = use_list_data(test_options( - vec![row("a", 1), row("b", 2), row("c", 3)], - ListSelection::Keys(HashSet::from(["a".into(), "c".into()])), - None, - )); - list.remove_selected_items.call(()); - dom_assert_eq!(item_keys(&list), vec!["b"]); - dom_assert!(matches!( - (list.selected_keys)(), - ListSelection::Keys(k) if k.is_empty() - )); - - let list = use_list_data(test_options( - vec![row("x", 5), row("y", 2)], - ListSelection::All, - None, - )); - list.remove_selected_items.call(()); - dom_assert!((list.items)().is_empty()); - rsx! { - div {} - } - } - - #[component] - fn move_single() -> Element { - let list = use_list_data(test_options( - vec![row("a", 1), row("b", 2), row("c", 3)], - empty_keys(), - None, - )); - list.r#move.call(("c".into(), 0)); - dom_assert_eq!(item_keys(&list), vec!["c", "a", "b"]); - - list.r#move.call(("v".into(), 1)); - dom_assert_eq!(item_keys(&list), vec!["c", "a", "b"]); - - list.r#move.call(("c".into(), 7)); - dom_assert_eq!(item_keys(&list), vec!["a", "b", "c"]); - - rsx! { - div {} - } - } - - #[component] - fn move_before() -> Element { - let list = use_list_data(test_options( - vec![ - row("a", 1), - row("b", 2), - row("c", 3), - row("d", 4), - row("e", 5), - ], - empty_keys(), - None, - )); - - list.move_before - .call(("f".into(), vec!["b".into(), "e".into()])); - dom_assert_eq!(item_keys(&list), vec!["a", "b", "c", "d", "e"]); - - list.move_before - .call(("d".into(), vec!["b".into(), "c".into()])); - dom_assert_eq!(item_keys(&list), vec!["a", "b", "c", "d", "e"]); - - list.move_before - .call(("c".into(), vec!["b".into(), "e".into()])); - dom_assert_eq!(item_keys(&list), vec!["a", "b", "e", "c", "d"]); - - list.move_before.call(("b".into(), vec!["c".into()])); - dom_assert_eq!(item_keys(&list), vec!["a", "c", "b", "e", "d"]); - - rsx! { - div {} - } - } - - #[component] - fn move_after() -> Element { - let list = use_list_data(test_options( - vec![ - row("a", 1), - row("b", 2), - row("c", 3), - row("d", 4), - row("e", 5), - ], - empty_keys(), - None, - )); - - list.move_after - .call(("f".into(), vec!["b".into(), "e".into()])); - dom_assert_eq!(item_keys(&list), vec!["a", "b", "c", "d", "e"]); - - list.move_after - .call(("с".into(), vec!["e".into(), "d".into()])); - dom_assert_eq!(item_keys(&list), vec!["a", "b", "c", "d", "e"]); - - list.move_after - .call(("c".into(), vec!["e".into(), "b".into()])); - dom_assert_eq!(item_keys(&list), vec!["a", "c", "b", "e", "d"]); - - list.move_after.call(("b".into(), vec!["a".into()])); - dom_assert_eq!(item_keys(&list), vec!["c", "b", "a", "e", "d"]); - - rsx! { - div {} - } - } - - #[component] - fn update_replace_and_map() -> Element { - let list = use_list_data(test_options( - vec![row("a", 1), row("b", 2)], - empty_keys(), - None, - )); - list.update - .call(("b".into(), UpdateValue::Replace(row("c", 99)))); - dom_assert_eq!((list.items)(), vec![row("a", 1), row("c", 99)]); - - list.update - .call(("b".into(), UpdateValue::Replace(row("b", 99)))); - dom_assert_eq!((list.items)(), vec![row("a", 1), row("c", 99)]); - - list.update.call(( - "a".into(), - UpdateValue::Map(Rc::new(|prev: Row| Row { - n: prev.n + 10, - ..prev - })), - )); - dom_assert_eq!((list.items)(), vec![row("a", 11), row("c", 99)]); - rsx! { - div {} - } - } - - #[test] - fn test_empty_data() { - run_dom!(empty_data); - } - - #[test] - fn test_no_selection() { - run_dom!(no_selection); - } - - #[test] - fn test_partial_selection() { - run_dom!(partial_selection); - } - - #[test] - fn test_selected_all() { - run_dom!(selected_all); - } - - #[test] - fn test_update_selection() { - run_dom!(update_selection); - } - - #[test] - fn test_append_prepend_insert_before_after() { - run_dom!(append_prepend_insert); - } - - #[test] - fn test_remove_and_get_item() { - run_dom!(remove_and_get_item); - } - - #[test] - fn test_set_add_remove_selected_keys() { - run_dom!(selection_set_add_remove); - } - - #[test] - fn test_filter() { - run_dom!(filter_and_filter_text); - } - - #[test] - fn test_remove_selected_items() { - run_dom!(remove_selected_items); - } - - #[test] - fn test_move_one_item() { - run_dom!(move_single); - } - - #[test] - fn test_move_before() { - run_dom!(move_before); - } - - #[test] - fn test_move_after() { - run_dom!(move_after); - } - - #[test] - fn test_update_replace_and_map() { - run_dom!(update_replace_and_map); - } - - #[component] - fn failing_dom_assert() -> Element { - dom_assert_eq!(1, 2); - rsx! { - div {} - } - } - - #[test] - #[should_panic(expected = "assertion `left == right` failed")] - fn run_dom_propagates_failed_assertion() { - run_dom!(failing_dom_assert); - } -} diff --git a/primitives/src/tag_group.rs b/primitives/src/tag_group.rs index 8e99e5a71..b779957c5 100644 --- a/primitives/src/tag_group.rs +++ b/primitives/src/tag_group.rs @@ -1,283 +1,486 @@ -//! Defines the [`TagGroup`] component and its sub-components. +//! Defines the [`TagGroup`] and [`TagGroupMulti`] components and their sub-components. use dioxus::prelude::*; -use crate::focus::{use_focus_controlled_item_disabled, use_focus_provider, FocusState}; -use crate::list_data::element_key; -use crate::{use_controlled, use_unique_id}; - -use std::collections::HashSet; - -/// The type of selection that is allowed in [`TagGroup`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum SelectionMode { - /// No selection (`aria-selected` is not set). - #[default] - None, - /// At most one tag may be selected. - Single, - /// Any number of tags may be selected. - Multiple, +use crate::{ + focus::{ + use_focus_controlled_item_disabled, use_focus_entry_disabled, use_focus_provider, + FocusState, + }, + selectable::SelectionMode, + selection::{option_text_value, remove_option, sync_option, OptionState, RcPartialEqValue}, + use_controlled, use_effect_cleanup, use_id_or, use_unique_id, +}; + +/// Selection and focus state for a tag group. +#[derive(Clone, Copy)] +pub(crate) struct TagSelectableContext { + values: Memo>, + set_value: Callback, + clear_selection: Callback<()>, + selection_mode: SelectionMode, + options: Signal>, + focus: FocusState, + disabled: ReadSignal, + selectable: ReadSignal, + allow_empty_selection: ReadSignal, } -/// Context provided by [`TagGroup`] to its descendants. -/// Use `use_context::()` to access list-level operations. +/// Context provided by [`TagGroup`] / [`TagGroupMulti`] to descendants. #[derive(Clone, Copy)] pub struct TagGroupCtx { - // State - list_items: Signal>, - // ID of the element that labels this group labeled_by: Signal>, - selection_mode: SelectionMode, - selected_tags: Memo>, - on_selection_change: Callback>, - disabled_tags: ReadSignal>, + allows_removing: ReadSignal, + escape_clears_selection: ReadSignal, + disabled_values: ReadSignal>, + removed: Signal>, + render_empty_state: Callback<(), Element>, +} - // Configuration - focus: FocusState, - group_disabled: ReadSignal, - allows_empty_selection: ReadSignal, +#[derive(Clone)] +struct TagOptionCtx { + value: RcPartialEqValue, + index: ReadSignal, +} + +struct TagGroupSharedProps { + label: Option, + disabled: ReadSignal, + selectable: ReadSignal, + disabled_values: ReadSignal>, + allow_empty_selection: ReadSignal, escape_clears_selection: ReadSignal, allows_removing: ReadSignal, + roving_loop: ReadSignal, render_empty_state: Callback<(), Element>, + attributes: Vec, + children: Element, } -impl TagGroupCtx { - /// Returns whether tags in this group show a remove control and can be deleted. - pub fn is_removable(&self) -> bool { - (self.allows_removing)() - } +struct TagGroupSelection { + values: Memo>, + set_value: Callback, + clear_selection: Callback<()>, + selection_mode: SelectionMode, +} - /// Returns the stable key for the tag at `index` - pub fn item_key(&self, index: usize) -> String { - (self.list_items)() - .get(index) - .map(|element| element_key(element, index)) - .unwrap_or_else(|| index.to_string()) +impl TagGroupSharedProps { + fn from_single(props: &TagGroupProps) -> Self { + Self { + label: props.label.clone(), + disabled: props.disabled, + selectable: props.selectable, + disabled_values: props.disabled_values, + allow_empty_selection: props.allow_empty_selection, + escape_clears_selection: props.escape_clears_selection, + allows_removing: props.allows_removing, + roving_loop: props.roving_loop, + render_empty_state: props.render_empty_state, + attributes: props.attributes.clone(), + children: props.children.clone(), + } } - fn is_tag_disabled(&self, key: &str) -> bool { - (self.group_disabled)() || (self.disabled_tags)().contains(key) + fn from_multi(props: &TagGroupMultiProps) -> Self { + Self { + label: props.label.clone(), + disabled: props.disabled, + selectable: props.selectable, + disabled_values: props.disabled_values, + allow_empty_selection: props.allow_empty_selection, + escape_clears_selection: props.escape_clears_selection, + allows_removing: props.allows_removing, + roving_loop: props.roving_loop, + render_empty_state: props.render_empty_state, + attributes: props.attributes.clone(), + children: props.children.clone(), + } } +} - fn is_tag_selected(&self, key: &str) -> bool { - (self.selected_tags)().contains(key) +impl TagGroupCtx { + /// Whether tags in this group show a remove control and can be deleted. + pub fn is_removable(&self) -> bool { + (self.allows_removing)() } - fn toggle_tag(&self, key: String) { - let allows_empty_selection = (self.allows_empty_selection)(); - let mut next = (self.selected_tags)().clone(); - match self.selection_mode { - SelectionMode::None => { - return; - } - SelectionMode::Single => { - if !next.contains(&key) { - next.clear(); - next.insert(key); - } else if allows_empty_selection || next.len() > 1 { - next.clear(); - } - } - SelectionMode::Multiple => { - if !next.contains(&key) { - next.insert(key); - } else if allows_empty_selection || next.len() > 1 { - next.remove(&key); - } + fn remove_value(&mut self, selectable: TagSelectableContext, value: RcPartialEqValue) { + let mut removed = self.removed.write(); + if removed.iter().any(|v| v == &value) { + return; + } + removed.push(value.clone()); + drop(removed); + + if selectable.is_selected(&value) { + match selectable.selection_mode { + SelectionMode::Single => selectable.clear_selection.call(()), + SelectionMode::Multiple => selectable.set_value.call(value), } } + } - self.on_selection_change.call(next); + fn is_removed(&self, value: &RcPartialEqValue) -> bool { + self.removed.read().iter().any(|v| v == value) } - fn clear_selection(&self) { - match self.selection_mode { - SelectionMode::None => {} - SelectionMode::Single | SelectionMode::Multiple => { - if (self.escape_clears_selection)() { - self.on_selection_change.call(HashSet::new()); - } - } - } + fn is_empty(&self, selectable: TagSelectableContext) -> bool { + selectable + .options + .read() + .iter() + .all(|option| self.is_removed(&option.value)) + } +} + +impl TagSelectableContext { + fn is_selected(&self, value: &RcPartialEqValue) -> bool { + self.values.read().iter().any(|v| v == value) } - /// Removes tags with the given keys from the list and clears them from the current selection. - pub fn remove_tags(&mut self, keys: HashSet) { - if keys.is_empty() { + fn toggle_value(&self, value: RcPartialEqValue) { + if !(self.selectable)() { return; } - let mut list = (self.list_items)(); - let mut indices: Vec = list - .iter() - .enumerate() - .filter_map(|(index, element)| { - keys.contains(&element_key(element, index)).then_some(index) - }) - .collect(); - indices.sort_unstable_by(|a, b| b.cmp(a)); - for index in indices { - let _ = list.remove(index); + let deselecting = self.is_selected(&value); + if !deselecting { + self.set_value.call(value); + return; } - self.list_items.set(list); - let mut selected = (self.selected_tags)().clone(); - for key in &keys { - selected.remove(key); - } - if selected != (self.selected_tags)() { - self.on_selection_change.call(selected); + let can_clear = match self.selection_mode { + SelectionMode::Single => (self.allow_empty_selection)(), + SelectionMode::Multiple => { + (self.allow_empty_selection)() || self.values.read().len() > 1 + } + }; + + if can_clear { + match self.selection_mode { + SelectionMode::Single => self.clear_selection.call(()), + SelectionMode::Multiple => self.set_value.call(value), + } } } - fn keyboard_remove(&mut self) { - if !(self.allows_removing)() - || self.selection_mode == SelectionMode::None - || (self.selected_tags)().is_empty() - { - return; + fn keyboard_remove_values(&self, focused: RcPartialEqValue) -> Vec { + if self.selection_mode == SelectionMode::Multiple && !self.values.read().is_empty() { + self.values.read().clone() + } else { + vec![focused] } - self.remove_tags((self.selected_tags)()); } } -/// The props for the [`TagGroup`] component. +/// Props for [`TagGroup`] (single selection). #[derive(Props, Clone, PartialEq)] -pub struct TagGroupProps { - /// Optional label above the tag group. +pub struct TagGroupProps { + /// Controlled selected value. `None` in the signal means no tag is selected. + #[props(default)] + pub value: Option>>, + + /// Initial value when uncontrolled. + #[props(default)] + pub default_value: Option, + + /// Called when the selected value changes. + #[props(default)] + pub on_value_change: Callback>, + + /// Optional visible label for the group, referenced by the tag list via `aria-labelledby`. #[props(default)] pub label: Option, - /// Tag content to render inside [`TagList`]. - pub items: Vec, + /// Whether the entire tag group is disabled. + #[props(default)] + pub disabled: ReadSignal, + + /// Whether tags can be selected. When `false`, tags remain focusable but not selectable. + #[props(default = ReadSignal::new(Signal::new(true)))] + pub selectable: ReadSignal, + + /// Values that cannot be selected or focused. + #[props(default = ReadSignal::new(Signal::new(Vec::new())))] + pub disabled_values: ReadSignal>, - /// The type of selection that is allowed in the group. + /// Whether clicking or pressing Space/Enter on the selected tag clears the selection. + #[props(default = ReadSignal::new(Signal::new(true)))] + pub allow_empty_selection: ReadSignal, + + /// Whether pressing Escape clears the current selection. + #[props(default = ReadSignal::new(Signal::new(true)))] + pub escape_clears_selection: ReadSignal, + + /// Whether tags can be removed via [`TagRemoveButton`] or Delete/Backspace. #[props(default)] - pub selection_mode: SelectionMode, + pub allows_removing: ReadSignal, + + /// Whether keyboard focus loops from the last tag to the first and vice versa. + #[props(default = ReadSignal::new(Signal::new(true)))] + pub roving_loop: ReadSignal, + + /// Content rendered inside [`TagList`] when there are no tags. + #[props(default = Callback::new(|_| rsx! { div { "No tags" } }))] + pub render_empty_state: Callback<(), Element>, - /// The currently selected tag keys (controlled). `None` means uncontrolled. + /// Additional attributes for the root element. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// The children of the tag group, typically a [`TagList`] with [`TagOption`] children. + pub children: Element, +} + +/// Props for [`TagGroupMulti`] (multiple selection). +#[derive(Props, Clone, PartialEq)] +pub struct TagGroupMultiProps { + /// Controlled selected values. #[props(default)] - pub selected_tags: ReadSignal>>, + pub values: ReadSignal>>, - /// The initial selected tag keys (uncontrolled). + /// Initial values when uncontrolled. #[props(default)] - pub default_selected_tags: HashSet, + pub default_values: Vec, - /// Handler that is called when the selection changes. + /// Called when the selected values change. #[props(default)] - pub on_selection_change: Callback>, + pub on_values_change: Callback>, - /// The tag keys that are disabled. These items cannot be selected, focused, or otherwise interacted with. - #[props(default = ReadSignal::new(Signal::new(HashSet::new())))] - pub disabled_tags: ReadSignal>, + /// Optional visible label for the group, referenced by the tag list via `aria-labelledby`. + #[props(default)] + pub label: Option, - /// Whether the tag group is disabled. + /// Whether the entire tag group is disabled. #[props(default)] pub disabled: ReadSignal, - /// Whether the collection allows empty selection. + /// Whether tags can be selected. When `false`, tags remain focusable but not selectable. + #[props(default = ReadSignal::new(Signal::new(true)))] + pub selectable: ReadSignal, + + /// Values that cannot be selected or focused. + #[props(default = ReadSignal::new(Signal::new(Vec::new())))] + pub disabled_values: ReadSignal>, + + /// Whether clicking or pressing Space/Enter on a selected tag deselects it. + /// When `false`, the last remaining selected tag cannot be deselected. #[props(default = ReadSignal::new(Signal::new(true)))] - pub allows_empty_selection: ReadSignal, + pub allow_empty_selection: ReadSignal, - /// Whether pressing the ESC key should clear selection in the TagGroup or not. + /// Whether pressing Escape clears the current selection. #[props(default = ReadSignal::new(Signal::new(true)))] pub escape_clears_selection: ReadSignal, - /// Shows a remove control on tags and enables Delete/Backspace removal. + /// Whether tags can be removed via [`TagRemoveButton`] or Delete/Backspace. #[props(default)] pub allows_removing: ReadSignal, - /// Whether focus should loop around when reaching the end. + /// Whether keyboard focus loops from the last tag to the first and vice versa. #[props(default = ReadSignal::new(Signal::new(true)))] pub roving_loop: ReadSignal, - /// Renders content when [`TagGroupProps::items`] is empty. - #[props(default = Callback::new(|_| rsx! { div { "No tags" }}))] + /// Content rendered inside [`TagList`] when there are no tags. + #[props(default = Callback::new(|_| rsx! { div { "No tags" } }))] pub render_empty_state: Callback<(), Element>, - /// Additional attributes to apply to the tag group element. + /// Additional attributes for the root element. #[props(extends = GlobalAttributes)] pub attributes: Vec, - /// The children of the tag group component. Defaults to [`TagList`]. - #[props(default)] - pub children: Option, + /// The children of the tag group, typically a [`TagList`] with [`TagOption`] children. + pub children: Element, } /// # TagGroup /// -/// A focusable group of tags with optional selection and removal. -/// Pass tag content via [`TagGroupProps::items`] (set a vnode `key` on each item) -/// and render them with [`TagList`] / [`Tag`], or customize the list in `children`. +/// A focusable group of tags with single selection. /// /// ## Example /// /// ```rust /// use dioxus::prelude::*; -/// use dioxus_primitives::tag_group::{TagGroup, SelectionMode}; -/// use std::collections::HashSet; +/// use dioxus_primitives::tag_group::{TagGroup, TagList, TagOption}; /// /// #[component] /// fn Demo() -> Element { -/// let items = ["bug", "feature"] -/// .into_iter() -/// .map(|label| rsx! { -/// span { key: "{label}", {label} } -/// }) -/// .collect::>(); +/// rsx! { +/// TagGroup::<&'static str> { +/// label: "Labels", +/// default_value: Some("bug"), +/// TagList { +/// TagOption::<&'static str> { index: 0usize, value: "bug", "bug" } +/// TagOption::<&'static str> { index: 1usize, value: "feature", disabled: true, "feature" } +/// } +/// } +/// } +/// } +/// ``` +#[component] +pub fn TagGroup(props: TagGroupProps) -> Element { + let mut internal_value: Signal> = use_signal(|| props.default_value.clone()); + let value = use_memo(move || match props.value { + Some(value) => value.cloned(), + None => internal_value.cloned(), + }); + let values = use_memo(move || value().map(RcPartialEqValue::new).into_iter().collect()); + let on_change = props.on_value_change; + let set_value = use_callback(move |incoming: RcPartialEqValue| { + let value = incoming + .as_ref::() + .unwrap_or_else(|| panic!("TagGroup and TagOption value types must match")) + .clone(); + internal_value.set(Some(value.clone())); + on_change.call(Some(value)); + }); + let clear_selection = use_callback(move |_| { + internal_value.set(None); + on_change.call(None); + }); + + tag_group_inner( + TagGroupSharedProps::from_single(&props), + TagGroupSelection { + values, + set_value, + clear_selection, + selection_mode: SelectionMode::Single, + }, + ) +} + +/// # TagGroupMulti /// -/// let mut selected = use_signal(|| HashSet::from(["bug".to_string()])); -/// let selected_tags = use_memo(move || Some(selected())); +/// A focusable group of tags with multiple selection. +/// +/// ## Example /// +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::tag_group::{TagGroupMulti, TagList, TagOption}; +/// +/// #[component] +/// fn Demo() -> Element { /// rsx! { -/// TagGroup { +/// TagGroupMulti::<&'static str> { /// label: "Labels", -/// items, -/// selection_mode: SelectionMode::Multiple, -/// selected_tags, -/// on_selection_change: move |tags| selected.set(tags), -/// allows_removing: true, +/// default_values: vec!["bug"], +/// TagList { +/// TagOption::<&'static str> { index: 0usize, value: "bug", "bug" } +/// TagOption::<&'static str> { index: 1usize, value: "feature", "feature" } +/// } /// } /// } /// } /// ``` #[component] -pub fn TagGroup(props: TagGroupProps) -> Element { - let label_id = use_unique_id(); - let mut labeled_by = use_signal(|| None); - labeled_by.set(props.label.as_ref().map(|_| label_id())); - - let (selected_tags, set_selected_tags) = use_controlled( - props.selected_tags, - props.default_selected_tags.clone(), - props.on_selection_change, +pub fn TagGroupMulti(props: TagGroupMultiProps) -> Element { + let (multi_values, set_multi_internal) = use_controlled( + props.values, + props.default_values.clone(), + props.on_values_change, ); - let list_items = use_signal(|| props.items.clone()); - let focus = use_focus_provider(props.roving_loop); + let values = use_memo(move || { + multi_values() + .into_iter() + .map(RcPartialEqValue::new) + .collect() + }); + let set_value = use_callback(move |value: RcPartialEqValue| { + let value_t = value + .as_ref::() + .unwrap_or_else(|| panic!("TagGroupMulti and TagOption value types must match")) + .clone(); + let mut current = multi_values(); + if let Some(pos) = current.iter().position(|v| v == &value_t) { + current.remove(pos); + } else { + current.push(value_t); + } + set_multi_internal.call(current); + }); + let clear_selection = use_callback(move |_| { + set_multi_internal.call(Vec::new()); + }); - use_context_provider(|| TagGroupCtx { - labeled_by, - selection_mode: props.selection_mode, - selected_tags, - on_selection_change: set_selected_tags, - disabled_tags: props.disabled_tags, - list_items, + tag_group_inner( + TagGroupSharedProps::from_multi(&props), + TagGroupSelection { + values, + set_value, + clear_selection, + selection_mode: SelectionMode::Multiple, + }, + ) +} + +fn tag_group_inner( + shared: TagGroupSharedProps, + selection: TagGroupSelection, +) -> Element { + let TagGroupSharedProps { + label, + disabled, + selectable, + disabled_values, + allow_empty_selection, + escape_clears_selection, + allows_removing, + roving_loop, + render_empty_state, + attributes, + children, + } = shared; + let TagGroupSelection { + values, + set_value, + clear_selection, + selection_mode, + } = selection; + + let label_id = use_unique_id(); + let disabled_values = use_memo(move || { + disabled_values + .read() + .iter() + .cloned() + .map(RcPartialEqValue::new) + .collect::>() + }); + let disabled_values = ReadSignal::new(disabled_values); + + let options: Signal> = use_signal(Vec::default); + let focus = use_focus_provider(roving_loop); + let removed: Signal> = use_signal(Vec::default); + + use_context_provider(|| TagSelectableContext { + values, + set_value, + clear_selection, + selection_mode, + options, focus, - group_disabled: props.disabled, - allows_empty_selection: props.allows_empty_selection, - escape_clears_selection: props.escape_clears_selection, - allows_removing: props.allows_removing, - render_empty_state: props.render_empty_state, + disabled, + selectable, + allow_empty_selection, }); - let children = props.children.unwrap_or_else(|| rsx! { TagList {} }); + let mut ctx = TagGroupCtx { + labeled_by: Signal::new(None), + allows_removing, + escape_clears_selection, + disabled_values, + removed, + render_empty_state, + }; + ctx.labeled_by.set(label.as_ref().map(|_| label_id())); + use_context_provider(|| ctx); rsx! { div { - ..props.attributes, - if let Some(label) = props.label { + ..attributes, + if let Some(label) = label { span { id: label_id(), {label} @@ -288,262 +491,239 @@ pub fn TagGroup(props: TagGroupProps) -> Element { } } -/// Data for rendering a tag in [`TagList`]. -#[derive(Clone, PartialEq)] -pub struct TagListRenderItem { - /// The current index of this tag. - pub index: usize, - /// The stable key for this tag. - pub key: String, - /// The rendered tag children. - pub children: Element, -} - -/// Returns render data for the current tags in [`TagGroup`]. -pub fn use_tag_list_items() -> Vec { - let ctx: TagGroupCtx = use_context(); - (ctx.list_items)() - .into_iter() - .enumerate() - .map(|(index, children)| TagListRenderItem { - index, - key: ctx.item_key(index), - children, - }) - .collect() -} - /// The props for the [`TagList`] component. #[derive(Props, Clone, PartialEq)] pub struct TagListProps { - /// Additional attributes to apply to the tag list element. + /// Additional attributes for the grid element. #[props(extends = GlobalAttributes)] pub attributes: Vec, - /// The children of the tag list component. Defaults to a [`Tag`] per item from [`TagGroupProps::items`]. - #[props(default)] - pub children: Option, + /// [`TagOption`] children rendered as rows in the grid. + pub children: Element, } -/// # TagList -/// -/// The inner grid element for tags inside a [`TagGroup`]. -/// Renders with one [`Tag`] per row by default. When [`TagGroupProps::items`] is empty, shows -/// [`TagGroupProps::render_empty_state`] instead of the list. -/// -/// This must be used inside a [`TagGroup`] component. -/// -/// ## Example -/// -/// ```rust -/// use dioxus::prelude::*; -/// use dioxus_primitives::tag_group::{Tag, TagGroup, TagList, use_tag_list_items}; -/// -/// #[component] -/// fn Demo() -> Element { -/// let items = ["bug", "feature"] -/// .into_iter() -/// .map(|label| rsx! { -/// span { key: "{label}", {label} } -/// }) -/// .collect::>(); -/// -/// rsx! { -/// TagGroup { -/// label: "Labels", -/// items, -/// TagList { -/// for item in use_tag_list_items() { -/// Tag { -/// key: "{item.key}", -/// index: item.index, -/// {item.children} -/// } -/// } -/// } -/// } -/// } -/// } -/// ``` +/// Grid container for [`TagOption`] children. #[component] pub fn TagList(props: TagListProps) -> Element { let ctx = use_context::(); - let is_empty = (ctx.list_items)().is_empty(); - - let children = props.children.unwrap_or_else(|| { - rsx! { - for item in use_tag_list_items() { - Tag { - key: "{item.key}", - index: item.index, - {item.children} - } - } - } - }); + let selectable = use_context::(); + let mut mounted = use_signal(|| false); + use_effect(move || mounted.set(true)); + let is_empty = use_memo(move || mounted() && ctx.is_empty(selectable)); rsx! { div { role: "grid", aria_labelledby: ctx.labeled_by, tabindex: "-1", - aria_multiselectable: if ctx.selection_mode == SelectionMode::Multiple { "true" }, + aria_multiselectable: if selectable.selection_mode == SelectionMode::Multiple + && (selectable.selectable)() + { + "true" + }, aria_colcount: "1", ..props.attributes, - if is_empty { + {props.children} + if is_empty() { {ctx.render_empty_state.call(())} - } else { - {children} } } } } -/// The props for the [`Tag`] component. +/// Props for [`TagOption`]. #[derive(Props, Clone, PartialEq)] -pub struct TagProps { - /// The index of the tag in the list. - pub index: usize, +pub struct TagOptionProps { + /// Programmatic value for this tag (selection and removal). + pub value: ReadSignal, + + /// Text used for the remove button label when no [`TagOptionProps::text_value`] is set. + #[props(default)] + pub text_value: ReadSignal>, - /// Whether this tag is disabled in addition to group-level [`TagGroupProps::disabled_tags`]. + /// Index for focus order and `aria-rowindex`. + pub index: ReadSignal, + + /// Optional ID for the tag row element. + #[props(default)] + pub id: ReadSignal>, + + /// Whether this tag is disabled. #[props(default)] pub disabled: ReadSignal, - /// Additional attributes to apply to the tag element. + /// Additional attributes for the tag row element. #[props(extends = GlobalAttributes)] pub attributes: Vec, - /// The children of the tag component. + /// The tag label and optional [`TagRemoveButton`]. pub children: Element, } -/// # Tag -/// -/// A single tag row inside [`TagList`]. -/// Handles focus, selection (Space/Enter), arrow-key navigation, and removal -/// (Delete/Backspace when [`TagGroupProps::allows_removing`] is enabled). -/// -/// Pass the list index from [`use_tag_list_items`] or from your own `enumerate()`. -/// This must be used within a [`TagGroup`] component. -/// -/// ## Example -/// -/// ```rust -/// use dioxus::prelude::*; -/// use dioxus_primitives::tag_group::{Tag, TagGroup, TagList, use_tag_list_items}; -/// -/// #[component] -/// fn Demo() -> Element { -/// let items = [rsx! { span { key: "{0}", "bug" } }].to_vec(); -/// -/// rsx! { -/// TagGroup { -/// items, -/// TagList { -/// for item in use_tag_list_items() { -/// Tag { -/// key: "{item.key}", -/// index: item.index, -/// {item.children} -/// } -/// } -/// } -/// } -/// } -/// } -/// ``` -/// -/// ## Styling -/// -/// The [`Tag`] component defines the following data attributes you can use to control styling: -/// - `data-selected`: `true` when the tag is selected. -/// - `data-disabled`: `true` when the tag is disabled. +fn tag_option_on_keydown( + e: Event, + mut ctx: TagGroupCtx, + mut selectable: TagSelectableContext, + value: RcPartialEqValue, + is_disabled: bool, + removable: bool, +) { + if is_disabled { + return; + } + + let key = e.key(); + let mut prevent_default = false; + + match key { + Key::Escape if (ctx.escape_clears_selection)() => { + selectable.clear_selection.call(()); + prevent_default = true; + } + Key::Character(s) if s == " " => { + selectable.toggle_value(value.clone()); + prevent_default = true; + } + Key::Enter => { + selectable.toggle_value(value.clone()); + prevent_default = true; + } + Key::Backspace | Key::Delete if removable && (selectable.selectable)() => { + for value in selectable.keyboard_remove_values(value) { + ctx.remove_value(selectable, value); + } + prevent_default = true; + } + Key::ArrowUp | Key::ArrowLeft => { + selectable.focus.focus_prev(); + prevent_default = true; + } + Key::ArrowDown | Key::ArrowRight => { + selectable.focus.focus_next(); + prevent_default = true; + } + Key::Home => { + selectable.focus.focus_first(); + prevent_default = true; + } + Key::End => { + selectable.focus.focus_last(); + prevent_default = true; + } + _ => {} + } + + if prevent_default { + e.prevent_default(); + } +} + +/// A single tag inside [`TagList`]. Must be used within [`TagGroup`] or [`TagGroupMulti`]. #[component] -pub fn Tag(props: TagProps) -> Element { +pub fn TagOption(props: TagOptionProps) -> Element { + let ctx: TagGroupCtx = use_context(); + let mut selectable = use_context::(); let index = props.index; - let mut ctx = use_context::(); - let tag_key = move || ctx.item_key(index); + let option_disabled = props.disabled; + let text_value_signal = props.text_value; + let option_value = props.value; + let value = use_memo(move || RcPartialEqValue::new(option_value.cloned())); + let is_removed = use_memo(move || ctx.is_removed(&value())); + + let disabled = { + let root_disabled = selectable.disabled; + let group_disabled_values = ctx.disabled_values; + use_memo(move || { + root_disabled.cloned() + || option_disabled.cloned() + || group_disabled_values.read().iter().any(|v| v == &value()) + }) + }; + + let generated_id = use_unique_id(); + let id = use_id_or(generated_id, props.id); + let mut previous_id: Signal> = use_signal(|| None); + let text_value = use_memo(move || { + option_text_value(&*option_value.read(), text_value_signal(), "TagOption") + }); + + use_effect(move || { + let option_id = id(); + let stale_id = previous_id + .peek() + .as_ref() + .filter(|stale_id| *stale_id != &option_id) + .cloned(); + if let Some(stale_id) = stale_id { + remove_option(selectable.options, &stale_id); + } + sync_option( + selectable.options, + OptionState { + tab_index: index(), + value: value(), + text_value: text_value.cloned(), + id: option_id.clone(), + disabled: disabled(), + }, + ); + previous_id.set(Some(option_id)); + }); + + use_effect_cleanup(move || { + if let Some(option_id) = previous_id.peek().as_ref() { + remove_option(selectable.options, option_id); + } + }); + + use_focus_entry_disabled(selectable.focus, index, move || disabled.cloned()); + + let selected = + use_memo(move || selectable.selectable.cloned() && selectable.is_selected(&value())); + + use_context_provider(|| TagOptionCtx { + value: value(), + index, + }); let tabindex = use_memo(move || { - if !(ctx.focus.roving_loop)() { + if !(selectable.focus.roving_loop)() { return "0"; } - if ctx.focus.recent_focus_or_default() == index { + if selectable.focus.recent_focus_or_default() == index.cloned() { "0" } else { "-1" } }); - let is_selected = move || ctx.is_tag_selected(&tag_key()); - let is_disabled = move || ctx.is_tag_disabled(&tag_key()) || (props.disabled)(); - let index_signal = use_memo(move || index); - let onmounted = use_focus_controlled_item_disabled(index_signal, is_disabled); + let onmounted = use_focus_controlled_item_disabled(index, move || disabled.cloned()); + let removable = ctx.allows_removing; - let onkeydown = move |e: Event| { - if is_disabled() { - return; - } - let event_key = e.key(); - let mut prevent_default = false; - - match event_key { - Key::Escape => { - ctx.clear_selection(); - prevent_default = true; - } - Key::Character(s) if s == " " => { - ctx.toggle_tag(tag_key()); - prevent_default = true; - } - Key::Enter => { - ctx.toggle_tag(tag_key()); - prevent_default = true; - } - Key::Backspace | Key::Delete => { - ctx.keyboard_remove(); - prevent_default = true; - } - Key::ArrowUp | Key::ArrowLeft => { - ctx.focus.focus_prev(); - prevent_default = true; - } - Key::ArrowDown | Key::ArrowRight => { - ctx.focus.focus_next(); - prevent_default = true; - } - Key::Home => { - ctx.focus.focus_first(); - prevent_default = true; - } - Key::End => { - ctx.focus.focus_last(); - prevent_default = true; - } - _ => {} - } - - if prevent_default { - e.prevent_default(); - } - }; + if is_removed() { + return rsx! {}; + } rsx! { div { role: "row", + id: id(), tabindex, - aria_selected: if ctx.selection_mode != SelectionMode::None { is_selected() }, - aria_disabled: is_disabled(), - "data-selected": is_selected(), - "data-disabled": is_disabled(), + aria_rowindex: (index.cloned() as i32) + 1, + aria_selected: (selectable.selectable)().then_some(selected()), + aria_disabled: disabled(), + "data-selected": selected(), + "data-disabled": disabled(), onmounted, - onfocus: move |_| ctx.focus.set_focus(Some(index)), - onkeydown, + onfocus: move |_| selectable.focus.set_focus(Some(index.cloned())), onclick: move |_| { - if !is_disabled() { - ctx.toggle_tag(tag_key()); + if !disabled() { + selectable.toggle_value(value()); } }, + onkeydown: move |e| { + tag_option_on_keydown(e, ctx, selectable, value(), disabled(), removable()); + }, ..props.attributes, div { role: "gridcell", @@ -554,3 +734,42 @@ pub fn Tag(props: TagProps) -> Element { } } } + +/// Remove button for the enclosing [`TagOption`]. Renders nothing when removal is disabled. +#[component] +pub fn TagRemoveButton( + #[props(extends = GlobalAttributes)] attributes: Vec, + children: Element, +) -> Element { + let mut ctx: TagGroupCtx = use_context(); + let selectable = use_context::(); + let option: TagOptionCtx = use_context(); + if !ctx.is_removable() { + return rsx! {}; + } + + let label = use_memo(move || { + let text = selectable + .options + .read() + .iter() + .find(|o| o.tab_index == option.index.cloned()) + .map(|o| o.text_value.clone()) + .unwrap_or_default(); + format!("Remove item {text}") + }); + + rsx! { + button { + r#type: "button", + tabindex: "-1", + aria_label: "{label}", + onclick: move |e| { + e.stop_propagation(); + ctx.remove_value(selectable, option.value.clone()); + }, + ..attributes, + {children} + } + } +} From 4a2d72ae4c315519c9829a162324a06609917c32 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Fri, 22 May 2026 13:20:08 +0300 Subject: [PATCH 06/10] [*] Tag Group: refact, update delete logic --- preview/src/components/tag_group/component.rs | 40 +- preview/src/components/tag_group/docs.md | 8 +- preview/src/components/tag_group/style.css | 15 +- .../components/tag_group/variants/main/mod.rs | 5 +- .../tag_group/variants/multi/mod.rs | 5 +- primitives/src/tag_group.rs | 346 +++++++++++------- 6 files changed, 257 insertions(+), 162 deletions(-) diff --git a/preview/src/components/tag_group/component.rs b/preview/src/components/tag_group/component.rs index 72a18becb..a5ef998c3 100644 --- a/preview/src/components/tag_group/component.rs +++ b/preview/src/components/tag_group/component.rs @@ -1,7 +1,8 @@ use dioxus::prelude::*; use dioxus_icons::lucide::X; use dioxus_primitives::tag_group::{ - self, TagGroupMultiProps, TagGroupProps, TagListProps, TagOptionProps, + self, TagGroupEmptyProps, TagGroupLabelProps, TagGroupMultiProps, TagGroupProps, TagListProps, + TagOptionProps, }; #[css_module("/src/components/tag_group/style.css")] @@ -12,18 +13,14 @@ pub fn TagGroup(props: TagGroupProps) -> Element { rsx! { tag_group::TagGroup { class: Styles::dx_tag_group, - label: props.label, value: props.value, default_value: props.default_value, on_value_change: props.on_value_change, disabled: props.disabled, selectable: props.selectable, - disabled_values: props.disabled_values, allow_empty_selection: props.allow_empty_selection, escape_clears_selection: props.escape_clears_selection, - allows_removing: props.allows_removing, roving_loop: props.roving_loop, - render_empty_state: props.render_empty_state, attributes: props.attributes, TagList { {props.children} @@ -37,18 +34,14 @@ pub fn TagGroupMulti(props: TagGroupMultiProps) -> Element { rsx! { tag_group::TagGroupMulti { class: Styles::dx_tag_group, - label: props.label, values: props.values, default_values: props.default_values, on_values_change: props.on_values_change, disabled: props.disabled, selectable: props.selectable, - disabled_values: props.disabled_values, allow_empty_selection: props.allow_empty_selection, escape_clears_selection: props.escape_clears_selection, - allows_removing: props.allows_removing, roving_loop: props.roving_loop, - render_empty_state: props.render_empty_state, attributes: props.attributes, TagList { {props.children} @@ -57,6 +50,29 @@ pub fn TagGroupMulti(props: TagGroupMultiProps) -> Element { } } +#[component] +pub fn TagGroupLabel(props: TagGroupLabelProps) -> Element { + rsx! { + tag_group::TagGroupLabel { + class: Styles::dx_tag_group_label, + id: props.id, + attributes: props.attributes, + {props.children} + } + } +} + +#[component] +pub fn TagGroupEmpty(props: TagGroupEmptyProps) -> Element { + rsx! { + tag_group::TagGroupEmpty { + class: Styles::dx_tag_group_empty, + attributes: props.attributes, + {props.children} + } + } +} + #[component] pub fn TagList(props: TagListProps) -> Element { rsx! { @@ -70,20 +86,18 @@ pub fn TagList(props: TagListProps) -> Element { #[component] pub fn Tag(props: TagOptionProps) -> Element { - let ctx = use_context::(); - let is_removable = ctx.is_removable(); - rsx! { tag_group::TagOption:: { class: Styles::dx_tag, value: props.value, text_value: props.text_value, disabled: props.disabled, + is_removable: props.is_removable, id: props.id, index: props.index, attributes: props.attributes, {props.children} - if is_removable { + if props.is_removable.cloned() { RemoveButton {} } } diff --git a/preview/src/components/tag_group/docs.md b/preview/src/components/tag_group/docs.md index 20ebe02fa..ee6d94d92 100644 --- a/preview/src/components/tag_group/docs.md +++ b/preview/src/components/tag_group/docs.md @@ -8,13 +8,11 @@ Single selection with [`TagGroup`](component.rs): ```rust TagGroup { - label: "Labels", value: Some(value.into()), on_value_change: move |value| { /* ... */ }, - allows_removing: true, - Tag { index: 0usize, value: "bug", "bug" } + TagGroupLabel { "Labels" } + TagGroupEmpty { "No tags" } + Tag { index: 0usize, value: "bug", is_removable: true, "bug" } Tag { index: 1usize, value: "feature", disabled: true, "feature" } } ``` - -Multiple selection with [`TagGroupMulti`](component.rs) — see the **multi** variant demo. diff --git a/preview/src/components/tag_group/style.css b/preview/src/components/tag_group/style.css index efd50285e..7b600f134 100644 --- a/preview/src/components/tag_group/style.css +++ b/preview/src/components/tag_group/style.css @@ -4,6 +4,12 @@ gap: 0.5rem; } +.dx-tag-group-label { + padding: 4px 12px; + font-size: 0.875rem; + font-weight: 500; +} + .dx-tag-list { display: flex; flex-wrap: wrap; @@ -57,8 +63,7 @@ } .dx-remove-button:hover { - background-color: var(--light, var(--primary-color-5)) - var(--dark, var(--primary-color-6)); + background-color: var(--light, var(--primary-color-5)) var(--dark, var(--primary-color-6)); color: var(--secondary-color-2); } @@ -69,4 +74,10 @@ .dx-remove-button:focus-visible { outline: 2px solid var(--focused-border-color); outline-offset: 2px; +} + +.dx-tag-group-empty { + padding: 4px 0; + font-size: 0.875rem; + text-align: center; } \ No newline at end of file diff --git a/preview/src/components/tag_group/variants/main/mod.rs b/preview/src/components/tag_group/variants/main/mod.rs index ab5480f6f..c1552e773 100644 --- a/preview/src/components/tag_group/variants/main/mod.rs +++ b/preview/src/components/tag_group/variants/main/mod.rs @@ -10,6 +10,7 @@ pub fn Demo() -> Element { Tag { index, value: t, + is_removable: true, "{t}" } } @@ -19,11 +20,11 @@ pub fn Demo() -> Element { rsx! { TagGroup { - label: "Labels", value: Some(value.into()), on_value_change: move |v| value.set(v), allow_empty_selection: false, - allows_removing: true, + TagGroupLabel { "Labels" } + TagGroupEmpty { "No tags" } {tags} } } diff --git a/preview/src/components/tag_group/variants/multi/mod.rs b/preview/src/components/tag_group/variants/multi/mod.rs index b724d1163..2265cfb97 100644 --- a/preview/src/components/tag_group/variants/multi/mod.rs +++ b/preview/src/components/tag_group/variants/multi/mod.rs @@ -10,6 +10,7 @@ pub fn Demo() -> Element { Tag { index, value: t, + is_removable: true, "{t}" } } @@ -20,11 +21,11 @@ pub fn Demo() -> Element { rsx! { TagGroupMulti { - label: "Labels", values: values_signal, on_values_change: move |v| values.set(v), allow_empty_selection: false, - allows_removing: true, + TagGroupLabel { "Labels" } + TagGroupEmpty { "No tags" } {tags} } } diff --git a/primitives/src/tag_group.rs b/primitives/src/tag_group.rs index b779957c5..0bf4da44a 100644 --- a/primitives/src/tag_group.rs +++ b/primitives/src/tag_group.rs @@ -9,7 +9,7 @@ use crate::{ }, selectable::SelectionMode, selection::{option_text_value, remove_option, sync_option, OptionState, RcPartialEqValue}, - use_controlled, use_effect_cleanup, use_id_or, use_unique_id, + use_controlled, use_effect_with_cleanup, use_id_or, use_unique_id, }; /// Selection and focus state for a tag group. @@ -30,29 +30,29 @@ pub(crate) struct TagSelectableContext { #[derive(Clone, Copy)] pub struct TagGroupCtx { labeled_by: Signal>, - allows_removing: ReadSignal, escape_clears_selection: ReadSignal, - disabled_values: ReadSignal>, removed: Signal>, - render_empty_state: Callback<(), Element>, +} + +/// Provided by [`TagList`] for [`TagGroupEmpty`]. +#[derive(Clone, Copy)] +struct TagListCtx { + show_empty: Memo, } #[derive(Clone)] struct TagOptionCtx { value: RcPartialEqValue, index: ReadSignal, + is_removable: ReadSignal, } -struct TagGroupSharedProps { - label: Option, +struct TagGroupSharedProps { disabled: ReadSignal, selectable: ReadSignal, - disabled_values: ReadSignal>, allow_empty_selection: ReadSignal, escape_clears_selection: ReadSignal, - allows_removing: ReadSignal, roving_loop: ReadSignal, - render_empty_state: Callback<(), Element>, attributes: Vec, children: Element, } @@ -64,34 +64,26 @@ struct TagGroupSelection { selection_mode: SelectionMode, } -impl TagGroupSharedProps { - fn from_single(props: &TagGroupProps) -> Self { +impl TagGroupSharedProps { + fn from_single(props: &TagGroupProps) -> Self { Self { - label: props.label.clone(), disabled: props.disabled, selectable: props.selectable, - disabled_values: props.disabled_values, allow_empty_selection: props.allow_empty_selection, escape_clears_selection: props.escape_clears_selection, - allows_removing: props.allows_removing, roving_loop: props.roving_loop, - render_empty_state: props.render_empty_state, attributes: props.attributes.clone(), children: props.children.clone(), } } - fn from_multi(props: &TagGroupMultiProps) -> Self { + fn from_multi(props: &TagGroupMultiProps) -> Self { Self { - label: props.label.clone(), disabled: props.disabled, selectable: props.selectable, - disabled_values: props.disabled_values, allow_empty_selection: props.allow_empty_selection, escape_clears_selection: props.escape_clears_selection, - allows_removing: props.allows_removing, roving_loop: props.roving_loop, - render_empty_state: props.render_empty_state, attributes: props.attributes.clone(), children: props.children.clone(), } @@ -99,11 +91,6 @@ impl TagGroupSharedProps { } impl TagGroupCtx { - /// Whether tags in this group show a remove control and can be deleted. - pub fn is_removable(&self) -> bool { - (self.allows_removing)() - } - fn remove_value(&mut self, selectable: TagSelectableContext, value: RcPartialEqValue) { let mut removed = self.removed.write(); if removed.iter().any(|v| v == &value) { @@ -164,8 +151,10 @@ impl TagSelectableContext { } } + /// Delete/Backspace targets: all selected tags when the focused tag is selected, + /// otherwise only the focused tag (even if other tags remain selected). fn keyboard_remove_values(&self, focused: RcPartialEqValue) -> Vec { - if self.selection_mode == SelectionMode::Multiple && !self.values.read().is_empty() { + if self.is_selected(&focused) { self.values.read().clone() } else { vec![focused] @@ -188,10 +177,6 @@ pub struct TagGroupProps { #[props(default)] pub on_value_change: Callback>, - /// Optional visible label for the group, referenced by the tag list via `aria-labelledby`. - #[props(default)] - pub label: Option, - /// Whether the entire tag group is disabled. #[props(default)] pub disabled: ReadSignal, @@ -200,10 +185,6 @@ pub struct TagGroupProps { #[props(default = ReadSignal::new(Signal::new(true)))] pub selectable: ReadSignal, - /// Values that cannot be selected or focused. - #[props(default = ReadSignal::new(Signal::new(Vec::new())))] - pub disabled_values: ReadSignal>, - /// Whether clicking or pressing Space/Enter on the selected tag clears the selection. #[props(default = ReadSignal::new(Signal::new(true)))] pub allow_empty_selection: ReadSignal, @@ -212,18 +193,10 @@ pub struct TagGroupProps { #[props(default = ReadSignal::new(Signal::new(true)))] pub escape_clears_selection: ReadSignal, - /// Whether tags can be removed via [`TagRemoveButton`] or Delete/Backspace. - #[props(default)] - pub allows_removing: ReadSignal, - /// Whether keyboard focus loops from the last tag to the first and vice versa. #[props(default = ReadSignal::new(Signal::new(true)))] pub roving_loop: ReadSignal, - /// Content rendered inside [`TagList`] when there are no tags. - #[props(default = Callback::new(|_| rsx! { div { "No tags" } }))] - pub render_empty_state: Callback<(), Element>, - /// Additional attributes for the root element. #[props(extends = GlobalAttributes)] pub attributes: Vec, @@ -247,10 +220,6 @@ pub struct TagGroupMultiProps { #[props(default)] pub on_values_change: Callback>, - /// Optional visible label for the group, referenced by the tag list via `aria-labelledby`. - #[props(default)] - pub label: Option, - /// Whether the entire tag group is disabled. #[props(default)] pub disabled: ReadSignal, @@ -259,10 +228,6 @@ pub struct TagGroupMultiProps { #[props(default = ReadSignal::new(Signal::new(true)))] pub selectable: ReadSignal, - /// Values that cannot be selected or focused. - #[props(default = ReadSignal::new(Signal::new(Vec::new())))] - pub disabled_values: ReadSignal>, - /// Whether clicking or pressing Space/Enter on a selected tag deselects it. /// When `false`, the last remaining selected tag cannot be deselected. #[props(default = ReadSignal::new(Signal::new(true)))] @@ -272,18 +237,10 @@ pub struct TagGroupMultiProps { #[props(default = ReadSignal::new(Signal::new(true)))] pub escape_clears_selection: ReadSignal, - /// Whether tags can be removed via [`TagRemoveButton`] or Delete/Backspace. - #[props(default)] - pub allows_removing: ReadSignal, - /// Whether keyboard focus loops from the last tag to the first and vice versa. #[props(default = ReadSignal::new(Signal::new(true)))] pub roving_loop: ReadSignal, - /// Content rendered inside [`TagList`] when there are no tags. - #[props(default = Callback::new(|_| rsx! { div { "No tags" } }))] - pub render_empty_state: Callback<(), Element>, - /// Additional attributes for the root element. #[props(extends = GlobalAttributes)] pub attributes: Vec, @@ -300,14 +257,14 @@ pub struct TagGroupMultiProps { /// /// ```rust /// use dioxus::prelude::*; -/// use dioxus_primitives::tag_group::{TagGroup, TagList, TagOption}; +/// use dioxus_primitives::tag_group::{TagGroup, TagGroupLabel, TagList, TagOption}; /// /// #[component] /// fn Demo() -> Element { /// rsx! { /// TagGroup::<&'static str> { -/// label: "Labels", /// default_value: Some("bug"), +/// TagGroupLabel { "Labels" } /// TagList { /// TagOption::<&'static str> { index: 0usize, value: "bug", "bug" } /// TagOption::<&'static str> { index: 1usize, value: "feature", disabled: true, "feature" } @@ -338,7 +295,7 @@ pub fn TagGroup(props: TagGroupProps) -> Elem on_change.call(None); }); - tag_group_inner( + use_tag_group_inner( TagGroupSharedProps::from_single(&props), TagGroupSelection { values, @@ -357,14 +314,14 @@ pub fn TagGroup(props: TagGroupProps) -> Elem /// /// ```rust /// use dioxus::prelude::*; -/// use dioxus_primitives::tag_group::{TagGroupMulti, TagList, TagOption}; +/// use dioxus_primitives::tag_group::{TagGroupLabel, TagGroupMulti, TagList, TagOption}; /// /// #[component] /// fn Demo() -> Element { /// rsx! { /// TagGroupMulti::<&'static str> { -/// label: "Labels", /// default_values: vec!["bug"], +/// TagGroupLabel { "Labels" } /// TagList { /// TagOption::<&'static str> { index: 0usize, value: "bug", "bug" } /// TagOption::<&'static str> { index: 1usize, value: "feature", "feature" } @@ -404,7 +361,7 @@ pub fn TagGroupMulti(props: TagGroupMultiProps(props: TagGroupMultiProps( - shared: TagGroupSharedProps, - selection: TagGroupSelection, -) -> Element { +fn use_tag_group_inner(shared: TagGroupSharedProps, selection: TagGroupSelection) -> Element { let TagGroupSharedProps { - label, disabled, selectable, - disabled_values, allow_empty_selection, escape_clears_selection, - allows_removing, roving_loop, - render_empty_state, attributes, children, } = shared; @@ -439,17 +389,6 @@ fn tag_group_inner( selection_mode, } = selection; - let label_id = use_unique_id(); - let disabled_values = use_memo(move || { - disabled_values - .read() - .iter() - .cloned() - .map(RcPartialEqValue::new) - .collect::>() - }); - let disabled_values = ReadSignal::new(disabled_values); - let options: Signal> = use_signal(Vec::default); let focus = use_focus_provider(roving_loop); let removed: Signal> = use_signal(Vec::default); @@ -466,31 +405,78 @@ fn tag_group_inner( allow_empty_selection, }); - let mut ctx = TagGroupCtx { + let ctx = TagGroupCtx { labeled_by: Signal::new(None), - allows_removing, escape_clears_selection, - disabled_values, removed, - render_empty_state, }; - ctx.labeled_by.set(label.as_ref().map(|_| label_id())); use_context_provider(|| ctx); rsx! { div { ..attributes, - if let Some(label) = label { - span { - id: label_id(), - {label} - } - } {children} } } } +/// Props for [`TagGroupLabel`]. +#[derive(Props, Clone, PartialEq)] +pub struct TagGroupLabelProps { + /// Optional ID for the label element. + #[props(default)] + pub id: ReadSignal>, + + /// Additional attributes for the label. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// Label content referenced by [`TagList`] via `aria-labelledby`. + pub children: Element, +} + +/// Visible label for a [`TagGroup`] or [`TagGroupMulti`], wired to the tag list through `aria-labelledby`. +/// +/// Must be used inside [`TagGroup`] or [`TagGroupMulti`]. +/// +/// ## Example +/// +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::tag_group::{TagGroup, TagGroupLabel, TagList, TagOption}; +/// +/// #[component] +/// fn Demo() -> Element { +/// rsx! { +/// TagGroup::<&'static str> { +/// TagGroupLabel { "Labels" } +/// TagList { +/// TagOption::<&'static str> { index: 0usize, value: "bug", "bug" } +/// } +/// } +/// } +/// } +/// ``` +#[component] +pub fn TagGroupLabel(props: TagGroupLabelProps) -> Element { + let mut ctx: TagGroupCtx = use_context(); + + let id = use_unique_id(); + let id = use_id_or(id, props.id); + + use_effect(move || { + ctx.labeled_by.set(Some(id())); + }); + + rsx! { + div { + id: id(), + ..props.attributes, + {props.children} + } + } +} + /// The props for the [`TagList`] component. #[derive(Props, Clone, PartialEq)] pub struct TagListProps { @@ -498,7 +484,7 @@ pub struct TagListProps { #[props(extends = GlobalAttributes)] pub attributes: Vec, - /// [`TagOption`] children rendered as rows in the grid. + /// [`TagOption`] children and an optional [`TagGroupEmpty`]. pub children: Element, } @@ -506,27 +492,85 @@ pub struct TagListProps { #[component] pub fn TagList(props: TagListProps) -> Element { let ctx = use_context::(); - let selectable = use_context::(); + let mut selectable = use_context::(); let mut mounted = use_signal(|| false); use_effect(move || mounted.set(true)); - let is_empty = use_memo(move || mounted() && ctx.is_empty(selectable)); + let show_empty = use_memo(move || mounted() && ctx.is_empty(selectable)); + + use_context_provider(|| TagListCtx { show_empty }); + + let list_tabbable = use_memo(move || { + !selectable.focus.any_focused() && selectable.focus.first_enabled_index().is_some() + }); rsx! { div { role: "grid", aria_labelledby: ctx.labeled_by, - tabindex: "-1", + tabindex: if list_tabbable() { "0" } else { "-1" }, aria_multiselectable: if selectable.selection_mode == SelectionMode::Multiple && (selectable.selectable)() { "true" }, aria_colcount: "1", + onfocus: move |_| { + if !selectable.focus.any_focused() { + selectable.focus.focus_first(); + } + }, + ..props.attributes, + {props.children} + } + } +} + +/// Props for [`TagGroupEmpty`]. +#[derive(Props, Clone, PartialEq)] +pub struct TagGroupEmptyProps { + /// Additional attributes for the empty state element. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// Content shown when every tag in the list has been removed. + pub children: Element, +} + +/// Renders when there are no tags left in the [`TagList`]. +/// +/// Must be used inside [`TagList`]. +/// +/// ## Example +/// +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::tag_group::{TagGroup, TagGroupEmpty, TagList, TagOption}; +/// +/// #[component] +/// fn Demo() -> Element { +/// rsx! { +/// TagGroup::<&'static str> { +/// TagList { +/// TagGroupEmpty { "No tags" } +/// TagOption::<&'static str> { index: 0usize, value: "bug", "bug" } +/// } +/// } +/// } +/// } +/// ``` +#[component] +pub fn TagGroupEmpty(props: TagGroupEmptyProps) -> Element { + let list = use_context::(); + + if !(list.show_empty)() { + return rsx! {}; + } + + rsx! { + div { + role: "presentation", ..props.attributes, {props.children} - if is_empty() { - {ctx.render_empty_state.call(())} - } } } } @@ -552,14 +596,32 @@ pub struct TagOptionProps { #[props(default)] pub disabled: ReadSignal, + /// Whether this tag can be removed via [`TagRemoveButton`] or Delete/Backspace. + #[props(default)] + pub is_removable: ReadSignal, + /// Additional attributes for the tag row element. #[props(extends = GlobalAttributes)] pub attributes: Vec, - /// The tag label and optional [`TagRemoveButton`]. + /// The tag label; add [`TagRemoveButton`] when [`TagOptionProps::is_removable`] is `true`. pub children: Element, } +/// After a tag is removed, restore roving focus when the deleted row had focus. +fn redirect_focus_after_tag_removal(mut focus: FocusState, had_focus: bool) { + if !had_focus || focus.current_focus().is_some() { + return; + } + focus.focus_next(); + if focus.current_focus().is_none() { + focus.focus_prev(); + } + if focus.current_focus().is_none() { + focus.focus_first(); + } +} + fn tag_option_on_keydown( e: Event, mut ctx: TagGroupCtx, @@ -625,6 +687,7 @@ pub fn TagOption(props: TagOptionProps) -> El let mut selectable = use_context::(); let index = props.index; let option_disabled = props.disabled; + let is_removable = props.is_removable; let text_value_signal = props.text_value; let option_value = props.value; let value = use_memo(move || RcPartialEqValue::new(option_value.cloned())); @@ -632,51 +695,48 @@ pub fn TagOption(props: TagOptionProps) -> El let disabled = { let root_disabled = selectable.disabled; - let group_disabled_values = ctx.disabled_values; - use_memo(move || { - root_disabled.cloned() - || option_disabled.cloned() - || group_disabled_values.read().iter().any(|v| v == &value()) - }) + use_memo(move || root_disabled.cloned() || option_disabled.cloned()) }; - let generated_id = use_unique_id(); - let id = use_id_or(generated_id, props.id); - let mut previous_id: Signal> = use_signal(|| None); + let id = use_id_or(use_unique_id(), props.id); let text_value = use_memo(move || { option_text_value(&*option_value.read(), text_value_signal(), "TagOption") }); use_effect(move || { - let option_id = id(); - let stale_id = previous_id - .peek() - .as_ref() - .filter(|stale_id| *stale_id != &option_id) - .cloned(); - if let Some(stale_id) = stale_id { - remove_option(selectable.options, &stale_id); + if !is_removed() { + return; } - sync_option( - selectable.options, - OptionState { - tab_index: index(), - value: value(), - text_value: text_value.cloned(), - id: option_id.clone(), - disabled: disabled(), - }, - ); - previous_id.set(Some(option_id)); + let idx = index(); + let had_focus = selectable.focus.is_focused(idx); + let option_id = id(); + remove_option(selectable.options, &option_id); + selectable.focus.remove_item(idx); + redirect_focus_after_tag_removal(selectable.focus, had_focus); }); - use_effect_cleanup(move || { - if let Some(option_id) = previous_id.peek().as_ref() { - remove_option(selectable.options, option_id); + use_effect_with_cleanup(move || { + let option_id = id(); + if !is_removed() { + sync_option( + selectable.options, + OptionState { + tab_index: index(), + value: value(), + text_value: text_value.cloned(), + id: option_id.clone(), + disabled: disabled(), + }, + ); + } + move || { + remove_option(selectable.options, &option_id); } }); - use_focus_entry_disabled(selectable.focus, index, move || disabled.cloned()); + use_focus_entry_disabled(selectable.focus, index, move || { + disabled.cloned() || is_removed() + }); let selected = use_memo(move || selectable.selectable.cloned() && selectable.is_selected(&value())); @@ -684,6 +744,7 @@ pub fn TagOption(props: TagOptionProps) -> El use_context_provider(|| TagOptionCtx { value: value(), index, + is_removable, }); let tabindex = use_memo(move || { @@ -698,7 +759,6 @@ pub fn TagOption(props: TagOptionProps) -> El }); let onmounted = use_focus_controlled_item_disabled(index, move || disabled.cloned()); - let removable = ctx.allows_removing; if is_removed() { return rsx! {}; @@ -722,7 +782,14 @@ pub fn TagOption(props: TagOptionProps) -> El } }, onkeydown: move |e| { - tag_option_on_keydown(e, ctx, selectable, value(), disabled(), removable()); + tag_option_on_keydown( + e, + ctx, + selectable, + value(), + disabled(), + is_removable.cloned(), + ); }, ..props.attributes, div { @@ -735,7 +802,9 @@ pub fn TagOption(props: TagOptionProps) -> El } } -/// Remove button for the enclosing [`TagOption`]. Renders nothing when removal is disabled. +/// Remove button for the enclosing [`TagOption`]. +/// +/// Must be used inside [`TagOption`] with [`TagOptionProps::is_removable`] set to `true`. #[component] pub fn TagRemoveButton( #[props(extends = GlobalAttributes)] attributes: Vec, @@ -744,7 +813,8 @@ pub fn TagRemoveButton( let mut ctx: TagGroupCtx = use_context(); let selectable = use_context::(); let option: TagOptionCtx = use_context(); - if !ctx.is_removable() { + + if !option.is_removable.cloned() { return rsx! {}; } From 37389c2e3bc3bebd60e119ff5a9241b916c9d060 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Fri, 29 May 2026 08:41:44 +0300 Subject: [PATCH 07/10] fix playwright --- playwright/tag_group.spec.ts | 14 +++++++------- .../src/components/tag_group/variants/multi/mod.rs | 2 ++ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/playwright/tag_group.spec.ts b/playwright/tag_group.spec.ts index 18835f190..7ba9f8c42 100644 --- a/playwright/tag_group.spec.ts +++ b/playwright/tag_group.spec.ts @@ -16,18 +16,18 @@ function tag(page: Page, name: string) { } async function loadTagGroup(page: Page) { - await page.goto(URL, { timeout: LOAD_TIMEOUT }); - await expect( - multiVariant(page).getByText("Labels", { exact: true }), - ).toBeVisible({ + await page.goto(URL, { timeout: LOAD_TIMEOUT, waitUntil: "networkidle" }); + const variant = multiVariant(page); + await variant.scrollIntoViewIfNeeded(); + await expect(variant.getByText("Labels", { exact: true })).toBeVisible({ timeout: 30000, }); - await expect(multiVariant(page).getByRole("grid")).toBeVisible(); + await expect(variant.getByRole("grid")).toBeVisible(); } test.describe("Tag group", () => { // One page load at a time — parallel navigations contend with the preview webServer build. - test.describe.configure({ mode: "serial" }); + test.describe.configure({ mode: "serial", timeout: LOAD_TIMEOUT }); test.beforeEach(async ({ page }) => { await loadTagGroup(page); @@ -153,7 +153,7 @@ test.describe("Tag group", () => { page, }) => { const results = await new AxeBuilder({ page }) - .include(".dx-component-variant [role=\"grid\"]") + .include('.dx-component-variant [role="grid"]') .disableRules(["color-contrast"]) .analyze(); expect(results.violations).toEqual([]); diff --git a/preview/src/components/tag_group/variants/multi/mod.rs b/preview/src/components/tag_group/variants/multi/mod.rs index 2265cfb97..004194873 100644 --- a/preview/src/components/tag_group/variants/multi/mod.rs +++ b/preview/src/components/tag_group/variants/multi/mod.rs @@ -6,10 +6,12 @@ use super::super::component::*; pub fn Demo() -> Element { let labels = ["bug", "feature", "core", "desktop", "example", "duplicate"]; let tags = labels.iter().enumerate().map(|(index, &t)| { + let disabled = matches!(t, "feature" | "example"); rsx! { Tag { index, value: t, + disabled, is_removable: true, "{t}" } From c8d2227691aa80e776801e9ae1dc2c5f9b54b7ee Mon Sep 17 00:00:00 2001 From: Evan Almloff Date: Fri, 29 May 2026 13:35:26 -0500 Subject: [PATCH 08/10] bump playwright to fix install --- playwright/package-lock.json | 26 +++++++++++++------------- playwright/package.json | 4 ++-- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/playwright/package-lock.json b/playwright/package-lock.json index 1447213e5..a8f485047 100644 --- a/playwright/package-lock.json +++ b/playwright/package-lock.json @@ -6,9 +6,9 @@ "": { "devDependencies": { "@axe-core/playwright": "^4.10.2", - "@playwright/test": "^1.53.0", + "@playwright/test": "^1.60.0", "axe-playwright": "^2.1.0", - "playwright": "^1.53.0" + "playwright": "^1.60.0" } }, "node_modules/@axe-core/playwright": { @@ -25,13 +25,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.53.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0.tgz", - "integrity": "sha512-15hjKreZDcp7t6TL/7jkAo6Df5STZN09jGiv5dbP9A6vMVncXRqE7/B2SncsyOwrkZRBH2i6/TPOL8BVmm3c7w==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.53.0" + "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -161,13 +161,13 @@ "license": "ISC" }, "node_modules/playwright": { - "version": "1.53.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0.tgz", - "integrity": "sha512-ghGNnIEYZC4E+YtclRn4/p6oYbdPiASELBIYkBXfaTVKreQUYbMUYQDwS12a8F0/HtIjr/CkGjtwABeFPGcS4Q==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.53.0" + "playwright-core": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -180,9 +180,9 @@ } }, "node_modules/playwright-core": { - "version": "1.53.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0.tgz", - "integrity": "sha512-mGLg8m0pm4+mmtB7M89Xw/GSqoNC+twivl8ITteqvAndachozYe2ZA7srU6uleV1vEdAHYqjq+SV8SNxRRFYBw==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/playwright/package.json b/playwright/package.json index 3c3b838f0..0362d82f8 100644 --- a/playwright/package.json +++ b/playwright/package.json @@ -1,8 +1,8 @@ { "devDependencies": { "@axe-core/playwright": "^4.10.2", - "@playwright/test": "^1.53.0", + "@playwright/test": "^1.60.0", "axe-playwright": "^2.1.0", - "playwright": "^1.53.0" + "playwright": "^1.60.0" } } From 045c2957cf5d3cb6dab805496a80ea0f059553f0 Mon Sep 17 00:00:00 2001 From: Evan Almloff Date: Tue, 2 Jun 2026 09:02:23 -0500 Subject: [PATCH 09/10] more consistant behavior - never lose focus when deleting items, remove is_removable, fix roving focus with disabled tags --- playwright/tag_group.spec.ts | 74 +++ preview/src/components/mod.rs | 2 +- preview/src/components/tag_group/component.rs | 24 +- .../tag_group/variants/states/mod.rs | 48 ++ primitives/src/tag_group.rs | 453 ++++++++++++------ 5 files changed, 448 insertions(+), 153 deletions(-) create mode 100644 preview/src/components/tag_group/variants/states/mod.rs diff --git a/playwright/tag_group.spec.ts b/playwright/tag_group.spec.ts index 7ba9f8c42..4ee3a0e4d 100644 --- a/playwright/tag_group.spec.ts +++ b/playwright/tag_group.spec.ts @@ -11,6 +11,12 @@ function multiVariant(page: Page) { .filter({ has: page.getByRole("heading", { name: "multi" }) }); } +function statesVariant(page: Page) { + return page + .locator(".dx-component-variant") + .filter({ has: page.getByRole("heading", { name: "states" }) }); +} + function tag(page: Page, name: string) { return multiVariant(page).getByRole("row", { name }); } @@ -73,10 +79,12 @@ test.describe("Tag group", () => { await expect(feature).toHaveAttribute("data-disabled", "true"); await expect(feature).toHaveAttribute("aria-disabled", "true"); + await expect(feature).toHaveAttribute("tabindex", "-1"); await expect(feature).toHaveAttribute("data-selected", "false"); await expect(example).toHaveAttribute("data-disabled", "true"); await expect(example).toHaveAttribute("aria-disabled", "true"); + await expect(example).toHaveAttribute("tabindex", "-1"); await expect(example).toHaveAttribute("data-selected", "false"); }); @@ -123,6 +131,7 @@ test.describe("Tag group", () => { test("Delete removes all selected tags", async ({ page }) => { const bug = tag(page, "bug"); const core = tag(page, "core"); + const desktop = tag(page, "desktop"); await core.click(); await expect(bug).toHaveAttribute("data-selected", "true"); @@ -133,6 +142,47 @@ test.describe("Tag group", () => { await expect(bug).toHaveCount(0); await expect(core).toHaveCount(0); + await expect(desktop).toBeFocused(); + }); + + test("Delete works for non-selectable removable tags", async ({ page }) => { + const group = statesVariant(page).getByTestId("tag-group-nonselectable"); + const alpha = group.getByRole("row", { name: "alpha" }); + const beta = group.getByRole("row", { name: "beta" }); + + await alpha.click(); + await expect(alpha).toBeFocused(); + + await page.keyboard.press("Delete"); + + await expect(alpha).toHaveCount(0); + await expect(beta).toBeFocused(); + }); + + test("Delete keeps selected tags that do not have a remove button", async ({ + page, + }) => { + const group = statesVariant(page).getByTestId( + "tag-group-mixed-removable", + ); + const bug = group.getByRole("row", { name: "bug" }); + const core = group.getByRole("row", { name: "core" }); + const desktop = group.getByRole("row", { name: "desktop" }); + + await expect(bug).toHaveAttribute("data-selected", "true"); + await expect(desktop).toHaveAttribute("data-selected", "true"); + + await core.click(); + await expect(core).toBeFocused(); + await expect(core).toHaveAttribute("data-selected", "true"); + + await page.keyboard.press("Delete"); + + await expect(bug).toHaveCount(0); + await expect(core).toHaveCount(0); + await expect(desktop).toBeVisible(); + await expect(desktop).toHaveAttribute("data-selected", "true"); + await expect(desktop).toBeFocused(); }); }); @@ -146,6 +196,30 @@ test.describe("Tag group", () => { .click(); await expect(bug).toHaveCount(0); }); + + test("disabled tags and groups disable remove buttons", async ({ page }) => { + const states = statesVariant(page); + const mixed = states.getByTestId("tag-group-mixed-removable"); + const groupDisabled = states.getByTestId("tag-group-disabled"); + + await expect( + mixed.getByRole("button", { name: "Remove item feature" }), + ).toBeDisabled(); + await expect(mixed.getByRole("row", { name: "feature" })).toHaveAttribute( + "tabindex", + "-1", + ); + + await expect( + groupDisabled.getByRole("button", { name: "Remove item locked" }), + ).toBeDisabled(); + await expect( + groupDisabled.getByRole("row", { name: "locked" }), + ).toHaveAttribute("tabindex", "-1"); + await expect( + groupDisabled.getByRole("row", { name: "archived" }), + ).toHaveAttribute("tabindex", "-1"); + }); }); test.describe("Accessibility", () => { diff --git a/preview/src/components/mod.rs b/preview/src/components/mod.rs index 345284f65..30dfc160a 100644 --- a/preview/src/components/mod.rs +++ b/preview/src/components/mod.rs @@ -206,7 +206,7 @@ examples!( slider[dynamic_range, range], switch, tabs, - tag_group[multi], + tag_group[multi, states], textarea[outline, fade, ghost], toast, toggle, diff --git a/preview/src/components/tag_group/component.rs b/preview/src/components/tag_group/component.rs index a5ef998c3..53a7f8808 100644 --- a/preview/src/components/tag_group/component.rs +++ b/preview/src/components/tag_group/component.rs @@ -2,7 +2,6 @@ use dioxus::prelude::*; use dioxus_icons::lucide::X; use dioxus_primitives::tag_group::{ self, TagGroupEmptyProps, TagGroupLabelProps, TagGroupMultiProps, TagGroupProps, TagListProps, - TagOptionProps, }; #[css_module("/src/components/tag_group/style.css")] @@ -84,15 +83,34 @@ pub fn TagList(props: TagListProps) -> Element { } } +/// Props for the demo [`Tag`] wrapper. `is_removable` is a preview-only toggle +/// that decides whether to render a [`RemoveButton`]; the primitive derives +/// removability from the presence of that button. +#[derive(Props, Clone, PartialEq)] +pub struct TagProps { + pub value: ReadSignal, + #[props(default)] + pub text_value: ReadSignal>, + pub index: ReadSignal, + #[props(default)] + pub id: ReadSignal>, + #[props(default)] + pub disabled: ReadSignal, + #[props(default)] + pub is_removable: ReadSignal, + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + pub children: Element, +} + #[component] -pub fn Tag(props: TagOptionProps) -> Element { +pub fn Tag(props: TagProps) -> Element { rsx! { tag_group::TagOption:: { class: Styles::dx_tag, value: props.value, text_value: props.text_value, disabled: props.disabled, - is_removable: props.is_removable, id: props.id, index: props.index, attributes: props.attributes, diff --git a/preview/src/components/tag_group/variants/states/mod.rs b/preview/src/components/tag_group/variants/states/mod.rs new file mode 100644 index 000000000..378bdb1c9 --- /dev/null +++ b/preview/src/components/tag_group/variants/states/mod.rs @@ -0,0 +1,48 @@ +use dioxus::prelude::*; + +use super::super::component::*; + +#[component] +pub fn Demo() -> Element { + let mut nonselectable_value = use_signal(|| Some("alpha".to_string())); + + let mut mixed_values = use_signal(|| vec!["bug".to_string(), "desktop".to_string()]); + let mixed_values_signal = use_memo(move || Some(mixed_values())); + + rsx! { + div { + TagGroup { + "data-testid": "tag-group-disabled", + disabled: true, + TagGroupLabel { "Group disabled" } + TagGroupEmpty { "No tags" } + Tag { index: 0usize, value: "locked", is_removable: true, "locked" } + Tag { index: 1usize, value: "archived", is_removable: true, "archived" } + } + + TagGroup { + "data-testid": "tag-group-nonselectable", + value: Some(nonselectable_value.into()), + on_value_change: move |value| nonselectable_value.set(value), + selectable: false, + TagGroupLabel { "Non-selectable removable" } + TagGroupEmpty { "No tags" } + Tag { index: 0usize, value: "alpha", is_removable: true, "alpha" } + Tag { index: 1usize, value: "beta", is_removable: true, "beta" } + Tag { index: 2usize, value: "gamma", is_removable: true, "gamma" } + } + + TagGroupMulti { + "data-testid": "tag-group-mixed-removable", + values: mixed_values_signal, + on_values_change: move |values| mixed_values.set(values), + TagGroupLabel { "Mixed removable" } + TagGroupEmpty { "No tags" } + Tag { index: 0usize, value: "bug", is_removable: true, "bug" } + Tag { index: 1usize, value: "core", is_removable: true, "core" } + Tag { index: 2usize, value: "desktop", "desktop" } + Tag { index: 3usize, value: "feature", disabled: true, is_removable: true, "feature" } + } + } + } +} diff --git a/primitives/src/tag_group.rs b/primitives/src/tag_group.rs index 0bf4da44a..bbea42ab1 100644 --- a/primitives/src/tag_group.rs +++ b/primitives/src/tag_group.rs @@ -3,23 +3,20 @@ use dioxus::prelude::*; use crate::{ - focus::{ - use_focus_controlled_item_disabled, use_focus_entry_disabled, use_focus_provider, - FocusState, - }, + focus::{use_focus_controlled_item_disabled, use_focus_provider, FocusState}, selectable::SelectionMode, - selection::{option_text_value, remove_option, sync_option, OptionState, RcPartialEqValue}, - use_controlled, use_effect_with_cleanup, use_id_or, use_unique_id, + selection::{option_text_value, RcPartialEqValue}, + use_controlled, use_effect_cleanup, use_effect_with_cleanup, use_id_or, use_unique_id, }; /// Selection and focus state for a tag group. #[derive(Clone, Copy)] -pub(crate) struct TagSelectableContext { +struct TagGroupState { values: Memo>, set_value: Callback, clear_selection: Callback<()>, selection_mode: SelectionMode, - options: Signal>, + items: Signal>, focus: FocusState, disabled: ReadSignal, selectable: ReadSignal, @@ -31,7 +28,7 @@ pub(crate) struct TagSelectableContext { pub struct TagGroupCtx { labeled_by: Signal>, escape_clears_selection: ReadSignal, - removed: Signal>, + state: TagGroupState, } /// Provided by [`TagList`] for [`TagGroupEmpty`]. @@ -42,9 +39,22 @@ struct TagListCtx { #[derive(Clone)] struct TagOptionCtx { + id: Signal, + /// Number of mounted [`TagRemoveButton`]s in this tag. The tag is removable + /// when this is greater than zero, so removability is driven purely by the + /// presence of a remove button rather than a separate prop. + remove_button_count: Signal, +} + +#[derive(Clone, PartialEq)] +struct TagItem { + id: String, + index: usize, value: RcPartialEqValue, - index: ReadSignal, - is_removable: ReadSignal, + text_value: String, + disabled: bool, + removable: bool, + removed: bool, } struct TagGroupSharedProps { @@ -90,37 +100,72 @@ impl TagGroupSharedProps { } } +impl TagItem { + fn is_focusable(&self) -> bool { + !self.disabled && !self.removed + } + + fn can_remove(&self) -> bool { + self.is_focusable() && self.removable + } +} + impl TagGroupCtx { - fn remove_value(&mut self, selectable: TagSelectableContext, value: RcPartialEqValue) { - let mut removed = self.removed.write(); - if removed.iter().any(|v| v == &value) { - return; - } - removed.push(value.clone()); - drop(removed); + fn is_empty(&self) -> bool { + self.state.items.read().iter().all(|item| item.removed) + } +} - if selectable.is_selected(&value) { - match selectable.selection_mode { - SelectionMode::Single => selectable.clear_selection.call(()), - SelectionMode::Multiple => selectable.set_value.call(value), - } +impl TagGroupState { + fn register_or_update_item(&mut self, mut item: TagItem) { + let mut items = self.items.write(); + if let Some(position) = items.iter().position(|existing| existing.id == item.id) { + item.removed = items[position].removed; + items.remove(position); } + insert_tag_item(&mut items, item); } - fn is_removed(&self, value: &RcPartialEqValue) -> bool { - self.removed.read().iter().any(|v| v == value) + fn unregister_item(&mut self, id: &str) { + self.items.write().retain(|item| item.id != id); } - fn is_empty(&self, selectable: TagSelectableContext) -> bool { - selectable - .options + fn is_removed(&self, id: &str) -> bool { + self.items .read() .iter() - .all(|option| self.is_removed(&option.value)) + .find(|item| item.id == id) + .map(|item| item.removed) + .unwrap_or(false) + } + + fn text_value(&self, id: &str) -> String { + self.items + .read() + .iter() + .find(|item| item.id == id) + .map(|item| item.text_value.clone()) + .unwrap_or_default() + } + + fn can_remove_item(&self, id: &str) -> bool { + self.items + .read() + .iter() + .find(|item| item.id == id) + .is_some_and(TagItem::can_remove) + } + + fn focus_item(&mut self, id: &str) { + let index = self + .items + .read() + .iter() + .find(|item| item.id == id && item.is_focusable()) + .map(|item| item.index); + self.focus.set_focus(index); } -} -impl TagSelectableContext { fn is_selected(&self, value: &RcPartialEqValue) -> bool { self.values.read().iter().any(|v| v == value) } @@ -151,15 +196,155 @@ impl TagSelectableContext { } } - /// Delete/Backspace targets: all selected tags when the focused tag is selected, - /// otherwise only the focused tag (even if other tags remain selected). - fn keyboard_remove_values(&self, focused: RcPartialEqValue) -> Vec { - if self.is_selected(&focused) { - self.values.read().clone() - } else { - vec![focused] + fn remove_item_from_button(&mut self, id: &str) -> bool { + self.remove_items(vec![id.to_string()]) + } + + fn remove_focused_from_keyboard(&mut self, focused_id: &str) -> bool { + let ids = self.keyboard_remove_item_ids(focused_id); + self.remove_items(ids) + } + + fn keyboard_remove_item_ids(&self, focused_id: &str) -> Vec { + let items = self.items.read(); + let Some(focused) = items.iter().find(|item| item.id == focused_id) else { + return Vec::new(); + }; + if !focused.can_remove() { + return Vec::new(); + } + + let selected_values = self.values.read().clone(); + let focused_selected = selected_values.iter().any(|value| value == &focused.value); + if !focused_selected { + return vec![focused.id.clone()]; + } + + items + .iter() + .filter(|item| { + item.can_remove() + && selected_values + .iter() + .any(|selected| selected == &item.value) + }) + .map(|item| item.id.clone()) + .collect() + } + + fn remove_items(&mut self, ids: Vec) -> bool { + let items = self.items.read(); + let selected_values = self.values.read().clone(); + let mut removal_ids = Vec::new(); + let mut removed_selected_values: Vec = Vec::new(); + + for id in ids { + if removal_ids.iter().any(|existing| existing == &id) { + continue; + } + let Some(item) = items.iter().find(|item| item.id == id) else { + continue; + }; + if !item.can_remove() { + continue; + } + if selected_values + .iter() + .any(|selected| selected == &item.value) + && !removed_selected_values + .iter() + .any(|selected| selected == &item.value) + { + removed_selected_values.push(item.value.clone()); + } + removal_ids.push(item.id.clone()); + } + + if removal_ids.is_empty() { + return false; + } + + let focus_target = self.focus.current_focus().and_then(|focused_index| { + items + .iter() + .any(|item| { + item.index == focused_index + && removal_ids.iter().any(|removed_id| removed_id == &item.id) + }) + .then(|| { + next_focus_after_removal( + &items, + focused_index, + &removal_ids, + (self.focus.roving_loop)(), + ) + }) + }); + drop(items); + drop(selected_values); + + if let Some(target) = focus_target { + self.focus.set_focus(target); + } + + { + let mut items = self.items.write(); + for item in items.iter_mut() { + if removal_ids.iter().any(|id| id == &item.id) { + item.removed = true; + } + } } + + if !removed_selected_values.is_empty() { + match self.selection_mode { + SelectionMode::Single => self.clear_selection.call(()), + SelectionMode::Multiple => { + for value in removed_selected_values { + self.set_value.call(value); + } + } + } + } + + true + } +} + +fn insert_tag_item(items: &mut Vec, item: TagItem) { + let insert_at = items.partition_point(|existing| existing.index <= item.index); + items.insert(insert_at, item); +} + +fn next_focus_after_removal( + items: &[TagItem], + focused_index: usize, + removal_ids: &[String], + roving_loop: bool, +) -> Option { + let candidates: Vec<&TagItem> = items + .iter() + .filter(|item| { + item.is_focusable() && !removal_ids.iter().any(|removed_id| removed_id == &item.id) + }) + .collect(); + + if candidates.is_empty() { + return None; + } + + let next_position = candidates.partition_point(|item| item.index <= focused_index); + if let Some(next) = candidates.get(next_position) { + return Some(next.index); + } + if roving_loop { + return candidates.first().map(|item| item.index); } + + let prev_position = candidates.partition_point(|item| item.index < focused_index); + prev_position + .checked_sub(1) + .and_then(|position| candidates.get(position).map(|item| item.index)) } /// Props for [`TagGroup`] (single selection). @@ -389,26 +574,25 @@ fn use_tag_group_inner(shared: TagGroupSharedProps, selection: TagGroupSelection selection_mode, } = selection; - let options: Signal> = use_signal(Vec::default); + let items: Signal> = use_signal(Vec::default); let focus = use_focus_provider(roving_loop); - let removed: Signal> = use_signal(Vec::default); - use_context_provider(|| TagSelectableContext { + let state = TagGroupState { values, set_value, clear_selection, selection_mode, - options, + items, focus, disabled, selectable, allow_empty_selection, - }); + }; let ctx = TagGroupCtx { - labeled_by: Signal::new(None), + labeled_by: use_signal(|| None), escape_clears_selection, - removed, + state, }; use_context_provider(|| ctx); @@ -492,31 +676,30 @@ pub struct TagListProps { #[component] pub fn TagList(props: TagListProps) -> Element { let ctx = use_context::(); - let mut selectable = use_context::(); + let mut state = ctx.state; let mut mounted = use_signal(|| false); use_effect(move || mounted.set(true)); - let show_empty = use_memo(move || mounted() && ctx.is_empty(selectable)); + let show_empty = use_memo(move || mounted() && ctx.is_empty()); use_context_provider(|| TagListCtx { show_empty }); - let list_tabbable = use_memo(move || { - !selectable.focus.any_focused() && selectable.focus.first_enabled_index().is_some() - }); + let list_tabbable = + use_memo(move || !state.focus.any_focused() && state.focus.first_enabled_index().is_some()); rsx! { div { role: "grid", aria_labelledby: ctx.labeled_by, tabindex: if list_tabbable() { "0" } else { "-1" }, - aria_multiselectable: if selectable.selection_mode == SelectionMode::Multiple - && (selectable.selectable)() + aria_multiselectable: if state.selection_mode == SelectionMode::Multiple + && (state.selectable)() { "true" }, aria_colcount: "1", onfocus: move |_| { - if !selectable.focus.any_focused() { - selectable.focus.focus_first(); + if !state.focus.any_focused() { + state.focus.focus_first(); } }, ..props.attributes, @@ -596,36 +779,20 @@ pub struct TagOptionProps { #[props(default)] pub disabled: ReadSignal, - /// Whether this tag can be removed via [`TagRemoveButton`] or Delete/Backspace. - #[props(default)] - pub is_removable: ReadSignal, - /// Additional attributes for the tag row element. #[props(extends = GlobalAttributes)] pub attributes: Vec, - /// The tag label; add [`TagRemoveButton`] when [`TagOptionProps::is_removable`] is `true`. + /// The tag label; add a [`TagRemoveButton`] to make the tag removable + /// (via click and via Delete/Backspace). pub children: Element, } -/// After a tag is removed, restore roving focus when the deleted row had focus. -fn redirect_focus_after_tag_removal(mut focus: FocusState, had_focus: bool) { - if !had_focus || focus.current_focus().is_some() { - return; - } - focus.focus_next(); - if focus.current_focus().is_none() { - focus.focus_prev(); - } - if focus.current_focus().is_none() { - focus.focus_first(); - } -} - fn tag_option_on_keydown( e: Event, - mut ctx: TagGroupCtx, - mut selectable: TagSelectableContext, + ctx: TagGroupCtx, + mut state: TagGroupState, + id: String, value: RcPartialEqValue, is_disabled: bool, removable: bool, @@ -639,37 +806,34 @@ fn tag_option_on_keydown( match key { Key::Escape if (ctx.escape_clears_selection)() => { - selectable.clear_selection.call(()); + state.clear_selection.call(()); prevent_default = true; } Key::Character(s) if s == " " => { - selectable.toggle_value(value.clone()); + state.toggle_value(value.clone()); prevent_default = true; } Key::Enter => { - selectable.toggle_value(value.clone()); + state.toggle_value(value.clone()); prevent_default = true; } - Key::Backspace | Key::Delete if removable && (selectable.selectable)() => { - for value in selectable.keyboard_remove_values(value) { - ctx.remove_value(selectable, value); - } - prevent_default = true; + Key::Backspace | Key::Delete if removable => { + prevent_default = state.remove_focused_from_keyboard(&id); } Key::ArrowUp | Key::ArrowLeft => { - selectable.focus.focus_prev(); + state.focus.focus_prev(); prevent_default = true; } Key::ArrowDown | Key::ArrowRight => { - selectable.focus.focus_next(); + state.focus.focus_next(); prevent_default = true; } Key::Home => { - selectable.focus.focus_first(); + state.focus.focus_first(); prevent_default = true; } Key::End => { - selectable.focus.focus_last(); + state.focus.focus_last(); prevent_default = true; } _ => {} @@ -684,81 +848,69 @@ fn tag_option_on_keydown( #[component] pub fn TagOption(props: TagOptionProps) -> Element { let ctx: TagGroupCtx = use_context(); - let mut selectable = use_context::(); + let mut state = ctx.state; let index = props.index; let option_disabled = props.disabled; - let is_removable = props.is_removable; + // Removability is driven by the presence of `TagRemoveButton` children, which + // increment this counter while mounted (see `TagRemoveButton`). + let remove_button_count = use_signal(|| 0usize); + let is_removable = use_memo(move || remove_button_count() > 0); let text_value_signal = props.text_value; let option_value = props.value; let value = use_memo(move || RcPartialEqValue::new(option_value.cloned())); - let is_removed = use_memo(move || ctx.is_removed(&value())); let disabled = { - let root_disabled = selectable.disabled; + let root_disabled = state.disabled; use_memo(move || root_disabled.cloned() || option_disabled.cloned()) }; let id = use_id_or(use_unique_id(), props.id); + let item_id = use_unique_id(); let text_value = use_memo(move || { option_text_value(&*option_value.read(), text_value_signal(), "TagOption") }); + let is_removed = use_memo(move || state.is_removed(&item_id())); use_effect(move || { - if !is_removed() { - return; - } - let idx = index(); - let had_focus = selectable.focus.is_focused(idx); - let option_id = id(); - remove_option(selectable.options, &option_id); - selectable.focus.remove_item(idx); - redirect_focus_after_tag_removal(selectable.focus, had_focus); + let option_id = item_id(); + state.register_or_update_item(TagItem { + id: option_id.clone(), + index: index(), + value: value(), + text_value: text_value.cloned(), + disabled: disabled(), + removable: is_removable(), + removed: false, + }); }); - - use_effect_with_cleanup(move || { - let option_id = id(); - if !is_removed() { - sync_option( - selectable.options, - OptionState { - tab_index: index(), - value: value(), - text_value: text_value.cloned(), - id: option_id.clone(), - disabled: disabled(), - }, - ); - } - move || { - remove_option(selectable.options, &option_id); - } + let mut cleanup_state = state; + use_effect_cleanup(move || { + cleanup_state.unregister_item(&item_id()); }); - use_focus_entry_disabled(selectable.focus, index, move || { - disabled.cloned() || is_removed() - }); - - let selected = - use_memo(move || selectable.selectable.cloned() && selectable.is_selected(&value())); + let selected = use_memo(move || state.selectable.cloned() && state.is_selected(&value())); use_context_provider(|| TagOptionCtx { - value: value(), - index, - is_removable, + id: item_id, + remove_button_count, }); let tabindex = use_memo(move || { - if !(selectable.focus.roving_loop)() { + if disabled() || is_removed() { + return "-1"; + } + if !(state.focus.roving_loop)() { return "0"; } - if selectable.focus.recent_focus_or_default() == index.cloned() { + if state.focus.recent_focus_or_default() == index.cloned() { "0" } else { "-1" } }); - let onmounted = use_focus_controlled_item_disabled(index, move || disabled.cloned()); + let onmounted = + use_focus_controlled_item_disabled(index, move || disabled.cloned() || is_removed()); if is_removed() { return rsx! {}; @@ -770,25 +922,26 @@ pub fn TagOption(props: TagOptionProps) -> El id: id(), tabindex, aria_rowindex: (index.cloned() as i32) + 1, - aria_selected: (selectable.selectable)().then_some(selected()), + aria_selected: (state.selectable)().then_some(selected()), aria_disabled: disabled(), "data-selected": selected(), "data-disabled": disabled(), onmounted, - onfocus: move |_| selectable.focus.set_focus(Some(index.cloned())), + onfocus: move |_| state.focus_item(&item_id()), onclick: move |_| { if !disabled() { - selectable.toggle_value(value()); + state.toggle_value(value()); } }, onkeydown: move |e| { tag_option_on_keydown( e, ctx, - selectable, + state, + item_id(), value(), disabled(), - is_removable.cloned(), + is_removable(), ); }, ..props.attributes, @@ -804,39 +957,41 @@ pub fn TagOption(props: TagOptionProps) -> El /// Remove button for the enclosing [`TagOption`]. /// -/// Must be used inside [`TagOption`] with [`TagOptionProps::is_removable`] set to `true`. +/// Must be used inside [`TagOption`]. Rendering this button makes the enclosing +/// tag removable, both via click and via Delete/Backspace keyboard removal. #[component] pub fn TagRemoveButton( #[props(extends = GlobalAttributes)] attributes: Vec, children: Element, ) -> Element { - let mut ctx: TagGroupCtx = use_context(); - let selectable = use_context::(); + let ctx: TagGroupCtx = use_context(); + let mut state = ctx.state; let option: TagOptionCtx = use_context(); - if !option.is_removable.cloned() { - return rsx! {}; - } + // Mark the enclosing tag removable while this button is mounted. + let mut remove_button_count = option.remove_button_count; + use_effect_with_cleanup(move || { + *remove_button_count.write() += 1; + move || { + *remove_button_count.write() -= 1; + } + }); let label = use_memo(move || { - let text = selectable - .options - .read() - .iter() - .find(|o| o.tab_index == option.index.cloned()) - .map(|o| o.text_value.clone()) - .unwrap_or_default(); + let text = state.text_value(&(option.id)()); format!("Remove item {text}") }); + let can_remove = use_memo(move || state.can_remove_item(&(option.id)())); rsx! { button { r#type: "button", tabindex: "-1", + disabled: !can_remove(), aria_label: "{label}", onclick: move |e| { e.stop_propagation(); - ctx.remove_value(selectable, option.value.clone()); + state.remove_item_from_button(&(option.id)()); }, ..attributes, {children} From 809beeb02da3b76ac625bc75d488cf9bad913ed5 Mon Sep 17 00:00:00 2001 From: Evan Almloff Date: Tue, 2 Jun 2026 09:23:52 -0500 Subject: [PATCH 10/10] remove is_removable from demos --- preview/src/components/tag_group/component.rs | 18 ++--------- preview/src/components/tag_group/docs.md | 8 +++-- .../components/tag_group/variants/main/mod.rs | 8 +++-- .../tag_group/variants/multi/mod.rs | 8 +++-- .../tag_group/variants/states/mod.rs | 30 +++++++++++-------- primitives/src/tag_group.rs | 9 ++++-- 6 files changed, 43 insertions(+), 38 deletions(-) diff --git a/preview/src/components/tag_group/component.rs b/preview/src/components/tag_group/component.rs index 53a7f8808..a02c52031 100644 --- a/preview/src/components/tag_group/component.rs +++ b/preview/src/components/tag_group/component.rs @@ -21,9 +21,7 @@ pub fn TagGroup(props: TagGroupProps) -> Element { escape_clears_selection: props.escape_clears_selection, roving_loop: props.roving_loop, attributes: props.attributes, - TagList { - {props.children} - } + {props.children} } } } @@ -42,9 +40,7 @@ pub fn TagGroupMulti(props: TagGroupMultiProps) -> Element { escape_clears_selection: props.escape_clears_selection, roving_loop: props.roving_loop, attributes: props.attributes, - TagList { - {props.children} - } + {props.children} } } } @@ -83,9 +79,6 @@ pub fn TagList(props: TagListProps) -> Element { } } -/// Props for the demo [`Tag`] wrapper. `is_removable` is a preview-only toggle -/// that decides whether to render a [`RemoveButton`]; the primitive derives -/// removability from the presence of that button. #[derive(Props, Clone, PartialEq)] pub struct TagProps { pub value: ReadSignal, @@ -96,8 +89,6 @@ pub struct TagProps { pub id: ReadSignal>, #[props(default)] pub disabled: ReadSignal, - #[props(default)] - pub is_removable: ReadSignal, #[props(extends = GlobalAttributes)] pub attributes: Vec, pub children: Element, @@ -115,15 +106,12 @@ pub fn Tag(props: TagProps) -> Element { index: props.index, attributes: props.attributes, {props.children} - if props.is_removable.cloned() { - RemoveButton {} - } } } } #[component] -fn RemoveButton( +pub fn RemoveButton( #[props(extends = GlobalAttributes)] attributes: Vec, children: Element, ) -> Element { diff --git a/preview/src/components/tag_group/docs.md b/preview/src/components/tag_group/docs.md index ee6d94d92..637c83350 100644 --- a/preview/src/components/tag_group/docs.md +++ b/preview/src/components/tag_group/docs.md @@ -11,8 +11,10 @@ TagGroup { value: Some(value.into()), on_value_change: move |value| { /* ... */ }, TagGroupLabel { "Labels" } - TagGroupEmpty { "No tags" } - Tag { index: 0usize, value: "bug", is_removable: true, "bug" } - Tag { index: 1usize, value: "feature", disabled: true, "feature" } + TagList { + TagGroupEmpty { "No tags" } + Tag { index: 0usize, value: "bug", "bug" RemoveButton {} } + Tag { index: 1usize, value: "feature", disabled: true, "feature" } + } } ``` diff --git a/preview/src/components/tag_group/variants/main/mod.rs b/preview/src/components/tag_group/variants/main/mod.rs index c1552e773..956e3580b 100644 --- a/preview/src/components/tag_group/variants/main/mod.rs +++ b/preview/src/components/tag_group/variants/main/mod.rs @@ -10,8 +10,8 @@ pub fn Demo() -> Element { Tag { index, value: t, - is_removable: true, "{t}" + RemoveButton {} } } }); @@ -24,8 +24,10 @@ pub fn Demo() -> Element { on_value_change: move |v| value.set(v), allow_empty_selection: false, TagGroupLabel { "Labels" } - TagGroupEmpty { "No tags" } - {tags} + TagList { + TagGroupEmpty { "No tags" } + {tags} + } } } } diff --git a/preview/src/components/tag_group/variants/multi/mod.rs b/preview/src/components/tag_group/variants/multi/mod.rs index 004194873..21fed8e36 100644 --- a/preview/src/components/tag_group/variants/multi/mod.rs +++ b/preview/src/components/tag_group/variants/multi/mod.rs @@ -12,8 +12,8 @@ pub fn Demo() -> Element { index, value: t, disabled, - is_removable: true, "{t}" + RemoveButton {} } } }); @@ -27,8 +27,10 @@ pub fn Demo() -> Element { on_values_change: move |v| values.set(v), allow_empty_selection: false, TagGroupLabel { "Labels" } - TagGroupEmpty { "No tags" } - {tags} + TagList { + TagGroupEmpty { "No tags" } + {tags} + } } } } diff --git a/preview/src/components/tag_group/variants/states/mod.rs b/preview/src/components/tag_group/variants/states/mod.rs index 378bdb1c9..00748185e 100644 --- a/preview/src/components/tag_group/variants/states/mod.rs +++ b/preview/src/components/tag_group/variants/states/mod.rs @@ -15,9 +15,11 @@ pub fn Demo() -> Element { "data-testid": "tag-group-disabled", disabled: true, TagGroupLabel { "Group disabled" } - TagGroupEmpty { "No tags" } - Tag { index: 0usize, value: "locked", is_removable: true, "locked" } - Tag { index: 1usize, value: "archived", is_removable: true, "archived" } + TagList { + TagGroupEmpty { "No tags" } + Tag { index: 0usize, value: "locked", "locked" RemoveButton {} } + Tag { index: 1usize, value: "archived", "archived" RemoveButton {} } + } } TagGroup { @@ -26,10 +28,12 @@ pub fn Demo() -> Element { on_value_change: move |value| nonselectable_value.set(value), selectable: false, TagGroupLabel { "Non-selectable removable" } - TagGroupEmpty { "No tags" } - Tag { index: 0usize, value: "alpha", is_removable: true, "alpha" } - Tag { index: 1usize, value: "beta", is_removable: true, "beta" } - Tag { index: 2usize, value: "gamma", is_removable: true, "gamma" } + TagList { + TagGroupEmpty { "No tags" } + Tag { index: 0usize, value: "alpha", "alpha" RemoveButton {} } + Tag { index: 1usize, value: "beta", "beta" RemoveButton {} } + Tag { index: 2usize, value: "gamma", "gamma" RemoveButton {} } + } } TagGroupMulti { @@ -37,11 +41,13 @@ pub fn Demo() -> Element { values: mixed_values_signal, on_values_change: move |values| mixed_values.set(values), TagGroupLabel { "Mixed removable" } - TagGroupEmpty { "No tags" } - Tag { index: 0usize, value: "bug", is_removable: true, "bug" } - Tag { index: 1usize, value: "core", is_removable: true, "core" } - Tag { index: 2usize, value: "desktop", "desktop" } - Tag { index: 3usize, value: "feature", disabled: true, is_removable: true, "feature" } + TagList { + TagGroupEmpty { "No tags" } + Tag { index: 0usize, value: "bug", "bug" RemoveButton {} } + Tag { index: 1usize, value: "core", "core" RemoveButton {} } + Tag { index: 2usize, value: "desktop", "desktop" } + Tag { index: 3usize, value: "feature", disabled: true, "feature" RemoveButton {} } + } } } } diff --git a/primitives/src/tag_group.rs b/primitives/src/tag_group.rs index bbea42ab1..4da5b5ae7 100644 --- a/primitives/src/tag_group.rs +++ b/primitives/src/tag_group.rs @@ -751,9 +751,14 @@ pub fn TagGroupEmpty(props: TagGroupEmptyProps) -> Element { rsx! { div { - role: "presentation", + role: "row", ..props.attributes, - {props.children} + div { + role: "gridcell", + aria_colindex: "1", + display: "contents", + {props.children} + } } } }