diff --git a/Cargo.lock b/Cargo.lock index 53d54bc9..ddb7a55a 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 c0986114..a80a3e54 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/package-lock.json b/playwright/package-lock.json index 1447213e..a8f48504 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 3c3b838f..0362d82f 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" } } diff --git a/playwright/tag_group.spec.ts b/playwright/tag_group.spec.ts new file mode 100644 index 00000000..4ee3a0e4 --- /dev/null +++ b/playwright/tag_group.spec.ts @@ -0,0 +1,236 @@ +import { test, expect, type Page } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; + +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 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 }); +} + +async function loadTagGroup(page: Page) { + 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(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", timeout: LOAD_TIMEOUT }); + + test.beforeEach(async ({ page }) => { + await loadTagGroup(page); + }); + + 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 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("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(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"); + }); + + 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"); + const desktop = tag(page, "desktop"); + + await core.click(); + await expect(bug).toHaveAttribute("data-selected", "true"); + await expect(core).toHaveAttribute("data-selected", "true"); + await expect(core).toBeFocused(); + + await page.keyboard.press("Delete"); + + 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(); + }); + }); + + test.describe("Removal", () => { + test("remove button deletes a tag", async ({ page }) => { + const bug = tag(page, "bug"); + await expect(bug).toBeVisible(); + + await multiVariant(page) + .getByRole("button", { name: "Remove item bug" }) + .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", () => { + test("has no automatically detectable a11y violations on the tag list", async ({ + page, + }) => { + const results = await new AxeBuilder({ page }) + .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 cad937b4..30dfc160 100644 --- a/preview/src/components/mod.rs +++ b/preview/src/components/mod.rs @@ -45,7 +45,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, } } @@ -206,6 +206,7 @@ examples!( slider[dynamic_range, range], switch, tabs, + tag_group[multi, states], 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 00000000..5d39fcb9 --- /dev/null +++ b/preview/src/components/tag_group/component.json @@ -0,0 +1,17 @@ +{ + "name": "tag_group", + "description": "A focusable group of tags (labels, categories, filters and similar 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 00000000..a02c5203 --- /dev/null +++ b/preview/src/components/tag_group/component.rs @@ -0,0 +1,126 @@ +use dioxus::prelude::*; +use dioxus_icons::lucide::X; +use dioxus_primitives::tag_group::{ + self, TagGroupEmptyProps, TagGroupLabelProps, TagGroupMultiProps, TagGroupProps, TagListProps, +}; + +#[css_module("/src/components/tag_group/style.css")] +struct Styles; + +#[component] +pub fn TagGroup(props: TagGroupProps) -> Element { + rsx! { + tag_group::TagGroup { + class: Styles::dx_tag_group, + value: props.value, + default_value: props.default_value, + on_value_change: props.on_value_change, + disabled: props.disabled, + selectable: props.selectable, + allow_empty_selection: props.allow_empty_selection, + escape_clears_selection: props.escape_clears_selection, + roving_loop: props.roving_loop, + attributes: props.attributes, + {props.children} + } + } +} + +#[component] +pub fn TagGroupMulti(props: TagGroupMultiProps) -> Element { + rsx! { + tag_group::TagGroupMulti { + class: Styles::dx_tag_group, + values: props.values, + default_values: props.default_values, + on_values_change: props.on_values_change, + disabled: props.disabled, + selectable: props.selectable, + allow_empty_selection: props.allow_empty_selection, + escape_clears_selection: props.escape_clears_selection, + roving_loop: props.roving_loop, + attributes: props.attributes, + {props.children} + } + } +} + +#[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! { + tag_group::TagList { + class: Styles::dx_tag_list, + attributes: props.attributes, + {props.children} + } + } +} + +#[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(extends = GlobalAttributes)] + pub attributes: Vec, + pub children: Element, +} + +#[component] +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, + id: props.id, + index: props.index, + attributes: props.attributes, + {props.children} + } + } +} + +#[component] +pub fn RemoveButton( + #[props(extends = GlobalAttributes)] attributes: Vec, + children: Element, +) -> Element { + rsx! { + tag_group::TagRemoveButton { + class: Styles::dx_remove_button, + 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 00000000..637c8335 --- /dev/null +++ b/preview/src/components/tag_group/docs.md @@ -0,0 +1,20 @@ +# Tag group + +A Tag Group is a focusable group of tags (labels, categories, filters and similar items) with keyboard navigation, optional selection and removal. + +## Structure + +Single selection with [`TagGroup`](component.rs): + +```rust +TagGroup { + value: Some(value.into()), + on_value_change: move |value| { /* ... */ }, + TagGroupLabel { "Labels" } + 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/mod.rs b/preview/src/components/tag_group/mod.rs new file mode 100644 index 00000000..2590c013 --- /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 00000000..7b600f13 --- /dev/null +++ b/preview/src/components/tag_group/style.css @@ -0,0 +1,83 @@ +.dx-tag-group { + display: flex; + flex-direction: column; + 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; + 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-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; +} + +.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 new file mode 100644 index 00000000..956e3580 --- /dev/null +++ b/preview/src/components/tag_group/variants/main/mod.rs @@ -0,0 +1,33 @@ +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}" + RemoveButton {} + } + } + }); + + let mut value = use_signal(|| Some("core".to_string())); + + rsx! { + TagGroup { + value: Some(value.into()), + on_value_change: move |v| value.set(v), + allow_empty_selection: false, + TagGroupLabel { "Labels" } + 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 new file mode 100644 index 00000000..21fed8e3 --- /dev/null +++ b/preview/src/components/tag_group/variants/multi/mod.rs @@ -0,0 +1,36 @@ +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)| { + let disabled = matches!(t, "feature" | "example"); + rsx! { + Tag { + index, + value: t, + disabled, + "{t}" + RemoveButton {} + } + } + }); + + let mut values = use_signal(|| vec!["bug".to_string()]); + let values_signal = use_memo(move || Some(values())); + + rsx! { + TagGroupMulti { + values: values_signal, + on_values_change: move |v| values.set(v), + allow_empty_selection: false, + TagGroupLabel { "Labels" } + 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 new file mode 100644 index 00000000..00748185 --- /dev/null +++ b/preview/src/components/tag_group/variants/states/mod.rs @@ -0,0 +1,54 @@ +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" } + TagList { + TagGroupEmpty { "No tags" } + Tag { index: 0usize, value: "locked", "locked" RemoveButton {} } + Tag { index: 1usize, value: "archived", "archived" RemoveButton {} } + } + } + + 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" } + 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 { + "data-testid": "tag-group-mixed-removable", + values: mixed_values_signal, + on_values_change: move |values| mixed_values.set(values), + TagGroupLabel { "Mixed removable" } + 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/lib.rs b/primitives/src/lib.rs index facfc1e6..ac565bbc 100644 --- a/primitives/src/lib.rs +++ b/primitives/src/lib.rs @@ -48,6 +48,7 @@ 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; diff --git a/primitives/src/tag_group.rs b/primitives/src/tag_group.rs new file mode 100644 index 00000000..4da5b5ae --- /dev/null +++ b/primitives/src/tag_group.rs @@ -0,0 +1,1005 @@ +//! 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}, + selectable::SelectionMode, + 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)] +struct TagGroupState { + values: Memo>, + set_value: Callback, + clear_selection: Callback<()>, + selection_mode: SelectionMode, + items: Signal>, + focus: FocusState, + disabled: ReadSignal, + selectable: ReadSignal, + allow_empty_selection: ReadSignal, +} + +/// Context provided by [`TagGroup`] / [`TagGroupMulti`] to descendants. +#[derive(Clone, Copy)] +pub struct TagGroupCtx { + labeled_by: Signal>, + escape_clears_selection: ReadSignal, + state: TagGroupState, +} + +/// Provided by [`TagList`] for [`TagGroupEmpty`]. +#[derive(Clone, Copy)] +struct TagListCtx { + show_empty: Memo, +} + +#[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, + text_value: String, + disabled: bool, + removable: bool, + removed: bool, +} + +struct TagGroupSharedProps { + disabled: ReadSignal, + selectable: ReadSignal, + allow_empty_selection: ReadSignal, + escape_clears_selection: ReadSignal, + roving_loop: ReadSignal, + attributes: Vec, + children: Element, +} + +struct TagGroupSelection { + values: Memo>, + set_value: Callback, + clear_selection: Callback<()>, + selection_mode: SelectionMode, +} + +impl TagGroupSharedProps { + fn from_single(props: &TagGroupProps) -> Self { + Self { + disabled: props.disabled, + selectable: props.selectable, + allow_empty_selection: props.allow_empty_selection, + escape_clears_selection: props.escape_clears_selection, + roving_loop: props.roving_loop, + attributes: props.attributes.clone(), + children: props.children.clone(), + } + } + + fn from_multi(props: &TagGroupMultiProps) -> Self { + Self { + disabled: props.disabled, + selectable: props.selectable, + allow_empty_selection: props.allow_empty_selection, + escape_clears_selection: props.escape_clears_selection, + roving_loop: props.roving_loop, + attributes: props.attributes.clone(), + children: props.children.clone(), + } + } +} + +impl TagItem { + fn is_focusable(&self) -> bool { + !self.disabled && !self.removed + } + + fn can_remove(&self) -> bool { + self.is_focusable() && self.removable + } +} + +impl TagGroupCtx { + fn is_empty(&self) -> bool { + self.state.items.read().iter().all(|item| item.removed) + } +} + +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 unregister_item(&mut self, id: &str) { + self.items.write().retain(|item| item.id != id); + } + + fn is_removed(&self, id: &str) -> bool { + self.items + .read() + .iter() + .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); + } + + fn is_selected(&self, value: &RcPartialEqValue) -> bool { + self.values.read().iter().any(|v| v == value) + } + + fn toggle_value(&self, value: RcPartialEqValue) { + if !(self.selectable)() { + return; + } + + let deselecting = self.is_selected(&value); + if !deselecting { + self.set_value.call(value); + return; + } + + 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 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). +#[derive(Props, Clone, PartialEq)] +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>, + + /// 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, + + /// 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 keyboard focus loops from the last tag to the first and vice versa. + #[props(default = ReadSignal::new(Signal::new(true)))] + pub roving_loop: ReadSignal, + + /// 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 values: ReadSignal>>, + + /// Initial values when uncontrolled. + #[props(default)] + pub default_values: Vec, + + /// Called when the selected values change. + #[props(default)] + pub on_values_change: Callback>, + + /// 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, + + /// 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 allow_empty_selection: ReadSignal, + + /// Whether pressing Escape clears the current selection. + #[props(default = ReadSignal::new(Signal::new(true)))] + pub escape_clears_selection: 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, + + /// 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, +} + +/// # TagGroup +/// +/// A focusable group of tags with single selection. +/// +/// ## Example +/// +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::tag_group::{TagGroup, TagGroupLabel, TagList, TagOption}; +/// +/// #[component] +/// fn Demo() -> Element { +/// rsx! { +/// TagGroup::<&'static str> { +/// 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" } +/// } +/// } +/// } +/// } +/// ``` +#[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); + }); + + use_tag_group_inner( + TagGroupSharedProps::from_single(&props), + TagGroupSelection { + values, + set_value, + clear_selection, + selection_mode: SelectionMode::Single, + }, + ) +} + +/// # TagGroupMulti +/// +/// A focusable group of tags with multiple selection. +/// +/// ## Example +/// +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::tag_group::{TagGroupLabel, TagGroupMulti, TagList, TagOption}; +/// +/// #[component] +/// fn Demo() -> Element { +/// rsx! { +/// TagGroupMulti::<&'static str> { +/// default_values: vec!["bug"], +/// TagGroupLabel { "Labels" } +/// TagList { +/// TagOption::<&'static str> { index: 0usize, value: "bug", "bug" } +/// TagOption::<&'static str> { index: 1usize, value: "feature", "feature" } +/// } +/// } +/// } +/// } +/// ``` +#[component] +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 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_tag_group_inner( + TagGroupSharedProps::from_multi(&props), + TagGroupSelection { + values, + set_value, + clear_selection, + selection_mode: SelectionMode::Multiple, + }, + ) +} + +fn use_tag_group_inner(shared: TagGroupSharedProps, selection: TagGroupSelection) -> Element { + let TagGroupSharedProps { + disabled, + selectable, + allow_empty_selection, + escape_clears_selection, + roving_loop, + attributes, + children, + } = shared; + let TagGroupSelection { + values, + set_value, + clear_selection, + selection_mode, + } = selection; + + let items: Signal> = use_signal(Vec::default); + let focus = use_focus_provider(roving_loop); + + let state = TagGroupState { + values, + set_value, + clear_selection, + selection_mode, + items, + focus, + disabled, + selectable, + allow_empty_selection, + }; + + let ctx = TagGroupCtx { + labeled_by: use_signal(|| None), + escape_clears_selection, + state, + }; + use_context_provider(|| ctx); + + rsx! { + div { + ..attributes, + {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 { + /// Additional attributes for the grid element. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// [`TagOption`] children and an optional [`TagGroupEmpty`]. + pub children: Element, +} + +/// Grid container for [`TagOption`] children. +#[component] +pub fn TagList(props: TagListProps) -> Element { + let ctx = 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()); + + use_context_provider(|| TagListCtx { show_empty }); + + 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 state.selection_mode == SelectionMode::Multiple + && (state.selectable)() + { + "true" + }, + aria_colcount: "1", + onfocus: move |_| { + if !state.focus.any_focused() { + state.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: "row", + ..props.attributes, + div { + role: "gridcell", + aria_colindex: "1", + display: "contents", + {props.children} + } + } + } +} + +/// Props for [`TagOption`]. +#[derive(Props, Clone, PartialEq)] +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>, + + /// 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 for the tag row element. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// The tag label; add a [`TagRemoveButton`] to make the tag removable + /// (via click and via Delete/Backspace). + pub children: Element, +} + +fn tag_option_on_keydown( + e: Event, + ctx: TagGroupCtx, + mut state: TagGroupState, + id: String, + 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)() => { + state.clear_selection.call(()); + prevent_default = true; + } + Key::Character(s) if s == " " => { + state.toggle_value(value.clone()); + prevent_default = true; + } + Key::Enter => { + state.toggle_value(value.clone()); + prevent_default = true; + } + Key::Backspace | Key::Delete if removable => { + prevent_default = state.remove_focused_from_keyboard(&id); + } + Key::ArrowUp | Key::ArrowLeft => { + state.focus.focus_prev(); + prevent_default = true; + } + Key::ArrowDown | Key::ArrowRight => { + state.focus.focus_next(); + prevent_default = true; + } + Key::Home => { + state.focus.focus_first(); + prevent_default = true; + } + Key::End => { + state.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 TagOption(props: TagOptionProps) -> Element { + let ctx: TagGroupCtx = use_context(); + let mut state = ctx.state; + let index = props.index; + let option_disabled = props.disabled; + // 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 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 || { + 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, + }); + }); + let mut cleanup_state = state; + use_effect_cleanup(move || { + cleanup_state.unregister_item(&item_id()); + }); + + let selected = use_memo(move || state.selectable.cloned() && state.is_selected(&value())); + + use_context_provider(|| TagOptionCtx { + id: item_id, + remove_button_count, + }); + + let tabindex = use_memo(move || { + if disabled() || is_removed() { + return "-1"; + } + if !(state.focus.roving_loop)() { + return "0"; + } + if state.focus.recent_focus_or_default() == index.cloned() { + "0" + } else { + "-1" + } + }); + + let onmounted = + use_focus_controlled_item_disabled(index, move || disabled.cloned() || is_removed()); + + if is_removed() { + return rsx! {}; + } + + rsx! { + div { + role: "row", + id: id(), + tabindex, + aria_rowindex: (index.cloned() as i32) + 1, + aria_selected: (state.selectable)().then_some(selected()), + aria_disabled: disabled(), + "data-selected": selected(), + "data-disabled": disabled(), + onmounted, + onfocus: move |_| state.focus_item(&item_id()), + onclick: move |_| { + if !disabled() { + state.toggle_value(value()); + } + }, + onkeydown: move |e| { + tag_option_on_keydown( + e, + ctx, + state, + item_id(), + value(), + disabled(), + is_removable(), + ); + }, + ..props.attributes, + div { + role: "gridcell", + aria_colindex: "1", + display: "contents", + {props.children} + } + } + } +} + +/// Remove button for the enclosing [`TagOption`]. +/// +/// 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 ctx: TagGroupCtx = use_context(); + let mut state = ctx.state; + let option: TagOptionCtx = use_context(); + + // 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 = 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(); + state.remove_item_from_button(&(option.id)()); + }, + ..attributes, + {children} + } + } +}