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/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" } } diff --git a/playwright/tag_group.spec.ts b/playwright/tag_group.spec.ts new file mode 100644 index 000000000..4ee3a0e4d --- /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/drag_and_drop_list/component.rs b/preview/src/components/drag_and_drop_list/component.rs index c66fe9b14..4b5c2dbde 100644 --- a/preview/src/components/drag_and_drop_list/component.rs +++ b/preview/src/components/drag_and_drop_list/component.rs @@ -80,6 +80,9 @@ pub fn DragAndDropListItem(props: DragAndDropListItemProps) -> Element { drag_and_drop_list::DragAndDropListItem { class: Styles::dx_dnd_list_item, index: props.index, + // Forward the stable item key so the primitive tracks focus by + // identity across reorders and removals instead of losing it. + item_key: props.item_key.clone(), attributes: props.attributes, {props.children} } @@ -102,6 +105,7 @@ pub fn DragAndDropListItems(props: DragAndDropListItemsProps) -> Element { } DragAndDropListItem { index: item.index, + item_key: item.key.clone(), {item.children} } DragAndDropDropIndicator { diff --git a/preview/src/components/mod.rs b/preview/src/components/mod.rs index cad937b4d..30dfc160a 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 000000000..5d39fcb97 --- /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 000000000..a02c52031 --- /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 000000000..637c83350 --- /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 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..7b600f134 --- /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 000000000..956e3580b --- /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 000000000..21fed8e36 --- /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 000000000..00748185e --- /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/accordion.rs b/primitives/src/accordion.rs index d8e04be5a..20ff3a122 100644 --- a/primitives/src/accordion.rs +++ b/primitives/src/accordion.rs @@ -1,7 +1,7 @@ //! Defines the [`Accordion`] component and its sub-components. +use crate::collection::{collection_item, use_item, CollectionOptions, CollectionState}; use crate::dioxus_elements::Key; -use crate::focus::{use_focus_control_disabled, use_focus_entry_disabled, FocusState}; use crate::{use_animated_open, use_id_or, use_unique_id}; use dioxus::prelude::*; @@ -30,7 +30,7 @@ struct AccordionContext { horizontal: ReadSignal, /// Roving focus state, keyed by the per-item runtime id. - focus: FocusState, + focus: CollectionState, } impl AccordionContext { @@ -47,7 +47,10 @@ impl AccordionContext { disabled, collapsible, horizontal, - focus: FocusState::new(ReadSignal::new(Signal::new(true))), + focus: CollectionState::new( + ReadSignal::new(Signal::new(true)), + CollectionOptions::default(), + ), } } @@ -195,7 +198,7 @@ pub fn Accordion(props: AccordionProps) -> Element { "data-disabled": (props.disabled)(), onfocusout: move |_| { - ctx.focus.blur(); + ctx.focus.clear_focus(); }, ..props.attributes, @@ -288,10 +291,6 @@ pub fn AccordionItem(props: AccordionItemProps) -> Element { on_trigger_click: props.on_trigger_click, }); - let disabled = move || ctx.is_disabled() || item.is_disabled(); - let id_signal = use_signal(|| item.id); - use_focus_entry_disabled(ctx.focus, id_signal, disabled); - // Open this item if we're set as default. use_hook(move || { if props.default_open { @@ -436,8 +435,9 @@ pub fn AccordionTrigger(props: AccordionTriggerProps) -> Element { let item: Item = use_context(); let disabled = move || ctx.is_disabled() || item.is_disabled(); + // The trigger is the focusable element, so it registers this accordion item. let id_signal = use_signal(|| item.id); - let onmounted = use_focus_control_disabled(ctx.focus, id_signal, disabled); + let onmounted = use_item(collection_item(ctx.focus, id_signal).disabled(disabled)).onmounted(); rsx! { button { diff --git a/primitives/src/collection.rs b/primitives/src/collection.rs new file mode 100644 index 000000000..4a3dbe101 --- /dev/null +++ b/primitives/src/collection.rs @@ -0,0 +1,653 @@ +//! Ordered interactive collection state shared by roving-focus components. + +use std::rc::Rc; + +use dioxus::prelude::*; + +use crate::use_effect_cleanup; + +#[derive(Clone, PartialEq)] +struct CollectionItemState { + index: usize, + key: Option, + disabled: bool, + hidden: bool, + selected: bool, +} + +impl CollectionItemState { + fn available(&self) -> bool { + !self.disabled && !self.hidden + } + + fn same_identity(&self, other: &Self) -> bool { + match (&self.key, &other.key) { + (Some(a), Some(b)) => a == b, + (None, None) => self.index == other.index, + _ => false, + } + } +} + +/// A requested initial focus placement. +#[derive(Clone, Copy)] +pub(crate) enum CollectionPlacement { + First, + Last, +} + +/// Group-level options for a collection. +#[derive(Clone, Copy, Default)] +pub(crate) struct CollectionOptions { + /// When no item is selected and none is focused, make every available item + /// a tab stop instead of only the first. Native HTML radio-group semantics. + pub(crate) tabbable_when_empty: bool, +} + +/// A cloneable handle for ordered item registration and roving-focus navigation. +#[derive(Clone, Copy)] +pub(crate) struct CollectionState { + roving_loop: ReadSignal, + tabbable_when_empty: bool, + recent: Signal>, + focused: Signal>, + focus_key: Signal>, + items: Signal>, +} + +impl CollectionState { + pub(crate) fn new(roving_loop: ReadSignal, options: CollectionOptions) -> Self { + Self { + roving_loop, + tabbable_when_empty: options.tabbable_when_empty, + recent: Signal::new(None), + focused: Signal::new(None), + focus_key: Signal::new(None), + items: Signal::new(Vec::new()), + } + } + + /// Whether arrow navigation wraps around the ends of the collection. + pub(crate) fn loops(&self) -> bool { + (self.roving_loop)() + } + + /// The backing loop signal, for nested collections that inherit the parent's + /// looping behavior. + pub(crate) fn loop_signal(&self) -> ReadSignal { + self.roving_loop + } + + fn register_item(&mut self, item: CollectionItemState) { + let index = item.index; + let key = item.key.clone(); + let available = item.available(); + if self.items.peek().iter().any(|existing| existing == &item) { + return; + } + let previous_index = self + .items + .peek() + .iter() + .find(|existing| existing.same_identity(&item)) + .map(|item| item.index); + sync_item(&mut self.items.write(), item); + if let Some(previous_index) = previous_index { + self.move_focus_for_reindexed_key(previous_index, index, key.as_deref()); + } else { + self.adopt_focus_key_for_index(index, key.clone()); + } + self.clear_focus_if_unavailable(index, key.as_deref(), available); + } + + fn unregister_item(&mut self, item: &CollectionItemState) { + let removing_focused = self.is_focused_item(item); + let removed_focused = { + let mut items = self.items.write(); + let removed = items.iter().any(|existing| existing.same_identity(item)); + items.retain(|existing| !existing.same_identity(item)); + removed && removing_focused + }; + + if removed_focused { + self.clear_focus(); + } + } + + pub(crate) fn set_focus(&mut self, index: Option) { + let target = match index { + Some(idx) if self.is_known_unavailable(idx) => None, + other => other, + }; + let key = target.and_then(|idx| self.key_for_index(idx)); + if let Some(idx) = target { + self.recent.set(Some(idx)); + } + // Only notify subscribers when the value actually changes. A redundant + // clear (clearing focus when nothing is focused) must not wake effects + // reading focus state, or it re-triggers the context-menu auto-close + // bug guarded against on `focused` below. + if *self.focus_key.peek() != key { + self.focus_key.set(key); + } + if *self.focused.peek() != target { + self.focused.set(target); + } + } + + pub(crate) fn set_focus_key(&mut self, key: Option) { + let index = key.as_deref().and_then(|key| self.index_for_key(key)); + if let Some(index) = index { + self.recent.set(Some(index)); + } + if *self.focused.peek() != index { + self.focused.set(index); + } + if *self.focus_key.peek() != key { + self.focus_key.set(key); + } + } + + pub(crate) fn clear_focus(&mut self) { + self.set_focus(None); + } + + pub(crate) fn focused_index(&self) -> Option { + (self.focused)() + } + + pub(crate) fn recent_focus(&self) -> Option { + (self.recent)() + } + + pub(crate) fn recent_focus_or_default(&self) -> usize { + self.recent_focus() + .filter(|&index| self.is_available(index)) + .or_else(|| self.selected_available_index()) + .or_else(|| self.first_available_index()) + .unwrap_or_default() + } + + pub(crate) fn focused_key(&self) -> Option { + let focused = self.focused_index()?; + let key = (self.focus_key)(); + let items = self.items.read(); + match key { + Some(key) => items + .iter() + .find(|item| item.available() && item.key.as_deref() == Some(key.as_str())) + .and_then(|item| item.key.clone()), + None => items + .iter() + .find(|item| item.index == focused && item.available()) + .and_then(|item| item.key.clone()), + } + } + + pub(crate) fn any_focused(&self) -> bool { + self.focused.read().is_some() + } + + pub(crate) fn is_focused(&self, index: usize) -> bool { + self.focused_index() == Some(index) + } + + pub(crate) fn is_available(&self, index: usize) -> bool { + self.items + .read() + .iter() + .any(|item| item.index == index && item.available()) + } + + pub(crate) fn first_available_index(&self) -> Option { + self.items + .read() + .iter() + .find(|item| item.available()) + .map(|item| item.index) + } + + pub(crate) fn last_available_index(&self) -> Option { + self.items + .read() + .iter() + .rev() + .find(|item| item.available()) + .map(|item| item.index) + } + + pub(crate) fn selected_available_index(&self) -> Option { + self.items + .read() + .iter() + .find(|item| item.selected && item.available()) + .map(|item| item.index) + } + + pub(crate) fn roving_tabindex(&self, index: usize) -> &'static str { + if !self.is_available(index) { + return "-1"; + } + if !self.loops() { + return "0"; + } + // The roving anchor: the most recently focused item, else the selected + // item, else the first available one. Mirrors React Aria's + // `focusedKey ?? firstSelectedKey ?? firstKey`. + let anchor = self + .recent_focus() + .filter(|&index| self.is_available(index)) + .or_else(|| self.selected_available_index()); + match anchor { + Some(anchor) => { + if anchor == index { + "0" + } else { + "-1" + } + } + // No focus and no selection: either every item is a tab stop + // (native radio-group semantics) or just the first one. + None if self.tabbable_when_empty => "0", + None if self.first_available_index() == Some(index) => "0", + None => "-1", + } + } + + pub(crate) fn focus_first(&mut self) { + self.set_focus(self.first_available_index()); + } + + pub(crate) fn focus_last(&mut self) { + self.set_focus(self.last_available_index()); + } + + pub(crate) fn focus_next(&mut self) { + let indices = self.available_indices(); + self.set_focus(next_index_after( + &indices, + self.recent_focus(), + self.loops(), + )); + } + + pub(crate) fn focus_prev(&mut self) { + let indices = self.available_indices(); + self.set_focus(prev_index_before( + &indices, + self.recent_focus(), + self.loops(), + )); + } + + /// Move focus to the next available item matching `predicate`, starting from + /// the currently focused item. The collection owns the ordering; callers only + /// describe which items qualify. + pub(crate) fn focus_next_matching(&mut self, predicate: impl Fn(usize) -> bool) { + let indices = self.available_indices_matching(predicate); + self.set_focus(next_index_after( + &indices, + self.focused_index(), + self.loops(), + )); + } + + /// Move focus to the previous available item matching `predicate`, starting + /// from the currently focused item. + pub(crate) fn focus_prev_matching(&mut self, predicate: impl Fn(usize) -> bool) { + let indices = self.available_indices_matching(predicate); + self.set_focus(prev_index_before( + &indices, + self.focused_index(), + self.loops(), + )); + } + + pub(crate) fn try_focus_placement(&mut self, placement: CollectionPlacement) -> bool { + let index = match placement { + CollectionPlacement::First => self.first_available_index(), + CollectionPlacement::Last => self.last_available_index(), + }; + if let Some(index) = index { + self.set_focus(Some(index)); + true + } else { + false + } + } + + fn control_mount_focus(&self, index: usize, controlled_ref: Signal>>) { + let controlled_ref = controlled_ref(); + if self.is_focused(index) && self.is_available(index) { + if let Some(md) = controlled_ref { + spawn(async move { + let _ = md.set_focus(true).await; + }); + } + } + } + + fn available_indices(&self) -> Vec { + self.available_indices_matching(|_| true) + } + + fn available_indices_matching(&self, predicate: impl Fn(usize) -> bool) -> Vec { + let mut indices: Vec<_> = self + .items + .read() + .iter() + .filter(|item| item.available() && predicate(item.index)) + .map(|item| item.index) + .collect(); + indices.sort_unstable(); + indices.dedup(); + indices + } + + fn is_known_unavailable(&self, index: usize) -> bool { + let items = self.items.peek(); + let mut known = false; + for item in items.iter().filter(|item| item.index == index) { + known = true; + if item.available() { + return false; + } + } + known + } + + fn key_for_index(&self, index: usize) -> Option { + let items = self.items.peek(); + items + .iter() + .find(|item| item.index == index && item.available()) + .or_else(|| items.iter().find(|item| item.index == index)) + .and_then(|item| item.key.clone()) + } + + fn index_for_key(&self, key: &str) -> Option { + self.items + .peek() + .iter() + .find(|item| item.key.as_deref() == Some(key) && item.available()) + .map(|item| item.index) + } + + fn clear_focus_if_unavailable(&mut self, index: usize, key: Option<&str>, available: bool) { + if available { + return; + } + let is_focused = match key { + Some(key) => self.focus_key.peek().as_deref() == Some(key), + None => self.focus_key.peek().is_none() && *self.focused.peek() == Some(index), + }; + if is_focused { + self.clear_focus(); + } + } + + fn move_focus_for_reindexed_key( + &mut self, + previous_index: usize, + index: usize, + key: Option<&str>, + ) { + if previous_index == index { + return; + } + + let moving_focused_item = match key { + Some(key) => self.focus_key.peek().as_deref() == Some(key), + None => self.focus_key.peek().is_none() && *self.focused.peek() == Some(previous_index), + }; + + if moving_focused_item { + self.focused.set(Some(index)); + self.recent.set(Some(index)); + } + } + + fn adopt_focus_key_for_index(&mut self, index: usize, key: Option) { + if key.is_none() { + return; + } + if *self.focused.peek() == Some(index) && self.focus_key.peek().is_none() { + self.focus_key.set(key); + } + } + + fn is_focused_item(&self, item: &CollectionItemState) -> bool { + match item.key.as_deref() { + Some(key) => self.focus_key.peek().as_deref() == Some(key), + None => self.focus_key.peek().is_none() && *self.focused.peek() == Some(item.index), + } + } +} + +pub(crate) fn use_collection_provider(roving_loop: ReadSignal) -> CollectionState { + use_collection_provider_with(roving_loop, CollectionOptions::default()) +} + +pub(crate) fn use_collection_provider_with( + roving_loop: ReadSignal, + options: CollectionOptions, +) -> CollectionState { + use_context_provider(|| CollectionState::new(roving_loop, options)) +} + +/// Everything an item needs from its collection, returned by the single +/// per-item entry point [`collection_item`]. Mirrors React Aria's +/// `useSelectableItem`, which returns one `itemProps` bundle (tabindex, focus +/// handling, focused state) instead of forcing each component to hand-roll it. +#[derive(Clone, Copy)] +pub(crate) struct CollectionItem { + /// Roving `tabindex` for the item's focusable element (`"0"` or `"-1"`). + pub(crate) tabindex: Memo<&'static str>, + focused: Memo, + controlled_ref: Signal>>, +} + +impl CollectionItem { + /// Whether this item is the currently focused one. + pub(crate) fn focused(&self) -> bool { + (self.focused)() + } + + /// A mounted handler that lets the collection drive DOM focus for this item. + /// Attach it to the focusable element. + pub(crate) fn onmounted(self) -> impl FnMut(MountedEvent) { + let mut controlled_ref = self.controlled_ref; + move |event: MountedEvent| controlled_ref.set(Some(event.data())) + } +} + +/// Begin registering one collection item. Chain only the optional inputs the +/// component actually has — `key`, `disabled`, `hidden`, `selected` — then pass +/// the builder to [`use_item`]. Unset inputs default to "none". +/// +/// This is a plain constructor and runs no hooks; the hook is [`use_item`]. +pub(crate) fn collection_item( + collection: CollectionState, + index: impl Readable + Copy + 'static, +) -> CollectionItemBuilder { + CollectionItemBuilder { + collection, + index: Rc::new(move || index.cloned()), + key: Rc::new(|| None), + disabled: Rc::new(|| false), + hidden: Rc::new(|| false), + selected: Rc::new(|| false), + } +} + +/// Builder for a single collection item. Construct it with [`collection_item`] +/// and pass it to [`use_item`] to register. +#[must_use = "pass the builder to use_item() to register the item"] +pub(crate) struct CollectionItemBuilder { + collection: CollectionState, + index: Rc usize>, + key: Rc Option>, + disabled: Rc bool>, + hidden: Rc bool>, + selected: Rc bool>, +} + +impl CollectionItemBuilder { + /// Whether the item is currently disabled (skipped by roving focus). + pub(crate) fn disabled(mut self, disabled: impl Fn() -> bool + 'static) -> Self { + self.disabled = Rc::new(disabled); + self + } + + /// The item key, used for focus identity and `focused_key()` lookups. + pub(crate) fn key(mut self, key: impl Fn() -> Option + 'static) -> Self { + self.key = Rc::new(key); + self + } + + /// Whether the item is currently hidden (e.g. filtered out or removed). + pub(crate) fn hidden(mut self, hidden: impl Fn() -> bool + 'static) -> Self { + self.hidden = Rc::new(hidden); + self + } + + /// Whether this item is the selected one. When nothing is focused yet, the + /// selected item becomes the roving tab stop — mirrors React Aria seeding + /// `focusedKey` from `firstSelectedKey`. + pub(crate) fn selected(mut self, selected: impl Fn() -> bool + 'static) -> Self { + self.selected = Rc::new(selected); + self + } +} + +/// Register an item builder and return its handle: a roving `tabindex`, +/// `focused()` state, and an `onmounted()` focus handler. This is the hook — it +/// calls `use_effect`/`use_signal`/`use_memo`, so call it unconditionally, once +/// per render. +pub(crate) fn use_item(builder: CollectionItemBuilder) -> CollectionItem { + let CollectionItemBuilder { + mut collection, + index, + key, + disabled, + hidden, + selected, + } = builder; + + let mut previous_item: Signal> = use_signal(|| None); + + use_effect({ + let index = index.clone(); + let disabled = disabled.clone(); + let hidden = hidden.clone(); + move || { + let item = CollectionItemState { + index: index(), + key: key(), + disabled: disabled(), + hidden: hidden(), + selected: selected(), + }; + let stale_item = previous_item.peek().clone(); + if let Some(stale_item) = stale_item { + if !stale_item.same_identity(&item) { + collection.unregister_item(&stale_item); + } + } + collection.register_item(item.clone()); + previous_item.set(Some(item)); + } + }); + + use_effect_cleanup(move || { + if let Some(item) = previous_item.peek().as_ref() { + collection.unregister_item(item); + } + }); + + // Keep DOM focus in sync when this item becomes focused. The handler that + // populates `controlled_ref` may be attached in a different component. + let controlled_ref: Signal>> = use_signal(|| None); + use_effect({ + let index = index.clone(); + move || { + if disabled() || hidden() { + return; + } + collection.control_mount_focus(index(), controlled_ref); + } + }); + + let tabindex = use_memo({ + let index = index.clone(); + move || collection.roving_tabindex(index()) + }); + let focused = use_memo(move || collection.is_focused(index())); + + CollectionItem { + tabindex, + focused, + controlled_ref, + } +} + +pub(crate) fn use_deferred_collection_focus( + mut collection: CollectionState, + mut placement: Signal>, + active: impl Fn() -> bool + Copy + 'static, +) { + use_effect(move || { + if !active() { + placement.set(None); + return; + } + let Some(placement_value) = placement() else { + return; + }; + if collection.try_focus_placement(placement_value) { + placement.set(None); + } + }); +} + +fn sync_item(items: &mut Vec, item: CollectionItemState) { + if let Some(position) = items + .iter() + .position(|existing| existing.same_identity(&item)) + { + items.remove(position); + } + let insert_at = items.partition_point(|existing| existing.index <= item.index); + items.insert(insert_at, item); +} + +fn next_index_after(indices: &[usize], current: Option, roving_loop: bool) -> Option { + match current { + Some(current) => { + let next_position = indices.partition_point(|&index| index <= current); + indices + .get(next_position) + .copied() + .or_else(|| roving_loop.then(|| indices.first().copied()).flatten()) + } + None => indices.first().copied(), + } +} + +fn prev_index_before( + indices: &[usize], + current: Option, + roving_loop: bool, +) -> Option { + match current { + Some(current) => { + let prev_position = indices.partition_point(|&index| index < current); + prev_position + .checked_sub(1) + .and_then(|position| indices.get(position).copied()) + .or_else(|| roving_loop.then(|| indices.last().copied()).flatten()) + } + None if roving_loop => indices.last().copied(), + None => indices.first().copied(), + } +} diff --git a/primitives/src/combobox/components/input.rs b/primitives/src/combobox/components/input.rs index 286d3af5a..0ce665cd3 100644 --- a/primitives/src/combobox/components/input.rs +++ b/primitives/src/combobox/components/input.rs @@ -132,7 +132,7 @@ pub fn ComboboxInput(props: ComboboxInputProps) -> Element { }; set_query.call(next_query); if was_open { - ctx.selectable.focus_state.set_focus(None); + ctx.selectable.collection.clear_focus(); } else { ctx.set_open(true); } diff --git a/primitives/src/combobox/components/option.rs b/primitives/src/combobox/components/option.rs index e40c9d8cb..44e5d7656 100644 --- a/primitives/src/combobox/components/option.rs +++ b/primitives/src/combobox/components/option.rs @@ -4,6 +4,7 @@ use dioxus::prelude::*; use super::super::context::ComboboxContext; use crate::{ + collection::{collection_item, use_item}, listbox::{ListboxContext, ListboxItemIndicator}, selectable::{ pointer_select_cancel, pointer_select_commit, pointer_select_start, use_selectable_option, @@ -66,6 +67,13 @@ pub fn ComboboxOption(props: ComboboxOptionProps component_name: "ComboboxOption", }, ); + use_item( + collection_item(ctx.selectable.collection, index) + .key(move || Some(option.id.cloned())) + .disabled(move || option.disabled.cloned()) + .hidden(move || !visible()) + .selected(move || (option.selected)()), + ); let render = use_context::().render; @@ -86,7 +94,7 @@ pub fn ComboboxOption(props: ComboboxOptionProps onmouseenter: move |_| { if !(option.disabled)() { - ctx.selectable.focus_state.set_focus(Some((option.index)())); + ctx.selectable.collection.set_focus(Some((option.index)())); } }, onpointerdown: move |event| { diff --git a/primitives/src/combobox/context.rs b/primitives/src/combobox/context.rs index 052760ac1..f33c798c2 100644 --- a/primitives/src/combobox/context.rs +++ b/primitives/src/combobox/context.rs @@ -20,7 +20,7 @@ pub(super) struct ComboboxContext { impl ComboboxContext { pub fn set_open(&mut self, open: bool) { if open { - self.selectable.focus_state.set_focus(None); + self.selectable.collection.clear_focus(); } self.selectable.set_open(open); } @@ -40,7 +40,7 @@ impl ComboboxContext { .options .read() .iter() - .find(|option| option.tab_index == tab_index) + .find(|option| option.index == tab_index) .is_some_and(predicate) } diff --git a/primitives/src/context_menu.rs b/primitives/src/context_menu.rs index 7d7e497f7..d612914ff 100644 --- a/primitives/src/context_menu.rs +++ b/primitives/src/context_menu.rs @@ -1,7 +1,7 @@ //! Defines the [`ContextMenu`] component and its subcomponents, which provide a context menu interface. use crate::{ - focus::{use_focus_controlled_item_disabled, use_focus_provider, FocusState}, + collection::{collection_item, use_collection_provider, use_item, CollectionState}, selectable::{pointer_select_cancel, pointer_select_commit, pointer_select_start}, use_animated_open, use_controlled, use_effect_with_cleanup, use_id_or, use_outside_dismiss, use_unique_id, @@ -44,7 +44,7 @@ struct ContextMenuCtx { position: Signal<(i32, i32)>, // Focus state - focus: FocusState, + focus: CollectionState, // Id on the root wrapper — covers both trigger and content, so // `use_outside_dismiss` treats them as "inside". @@ -143,7 +143,7 @@ pub fn ContextMenu(props: ContextMenuProps) -> Element { let root_id = use_unique_id(); let long_press_just_fired = use_signal(|| false); - let focus = use_focus_provider(props.roving_loop); + let focus = use_collection_provider(props.roving_loop); let mut ctx = use_context_provider(|| ContextMenuCtx { open, set_open, @@ -166,7 +166,7 @@ pub fn ContextMenu(props: ContextMenuProps) -> Element { if open() && event.key() == Key::Escape { event.prevent_default(); set_open.call(false); - ctx.focus.blur(); + ctx.focus.clear_focus(); } }; @@ -418,7 +418,7 @@ pub fn ContextMenuContent(props: ContextMenuContentProps) -> Element { let onkeydown = move |event: Event| { match event.key() { - Key::Escape => ctx.focus.blur(), + Key::Escape => ctx.focus.clear_focus(), Key::ArrowDown => { ctx.focus.focus_next(); } @@ -455,7 +455,7 @@ pub fn ContextMenuContent(props: ContextMenuContentProps) -> Element { let render = use_animated_open(id, open); use_outside_dismiss(ctx.root_id, move || { - ctx.focus.blur(); + ctx.focus.clear_focus(); ctx.set_open.call(false); }); @@ -501,7 +501,7 @@ pub fn ContextMenuContent(props: ContextMenuContentProps) -> Element { onkeydown, onblur: move |_| { if focused() { - ctx.focus.blur(); + ctx.focus.clear_focus(); } }, onmounted: move |evt| menu_ref.set(Some(evt.data())), @@ -594,9 +594,10 @@ pub fn ContextMenuItem(props: ContextMenuItemProps) -> Element { let mut ctx: ContextMenuCtx = use_context(); let disabled = move || (props.disabled)() || (ctx.disabled)(); - let focused = move || ctx.focus.is_focused(props.index.cloned()); + let item = use_item(collection_item(ctx.focus, props.index).disabled(disabled)); + let focused = move || item.focused(); - let onmounted = use_focus_controlled_item_disabled(props.index, disabled); + let onmounted = item.onmounted(); let tab_index = use_memo(move || if focused() { "0" } else { "-1" }); @@ -609,7 +610,7 @@ pub fn ContextMenuItem(props: ContextMenuItemProps) -> Element { let mut select = move || { if !disabled() { props.on_select.call((value)()); - ctx.focus.blur(); + ctx.focus.clear_focus(); ctx.set_open.call(false); } }; @@ -642,7 +643,7 @@ pub fn ContextMenuItem(props: ContextMenuItemProps) -> Element { onkeydown: handle_keydown, onblur: move |_| { if focused() { - ctx.focus.blur(); + ctx.focus.clear_focus(); } }, onmounted, diff --git a/primitives/src/date_picker.rs b/primitives/src/date_picker.rs index 19d877d2b..b45c1d7ca 100644 --- a/primitives/src/date_picker.rs +++ b/primitives/src/date_picker.rs @@ -4,8 +4,8 @@ use crate::{ calendar::{ weekday_abbreviation, AvailableRanges, CalendarProps, DateRange, RangeCalendarProps, }, + collection::{collection_item, use_collection_provider, use_item, CollectionState}, dioxus_core::Properties, - focus::{use_focus_controlled_item_disabled, use_focus_provider, FocusState}, popover::*, use_unique_id, LocalDateExt as _, }; @@ -24,7 +24,7 @@ struct BaseDatePickerContext { // Configuration disabled: ReadSignal, - focus: FocusState, + focus: CollectionState, enabled_date_range: DateRange, available_ranges: Memo, } @@ -134,7 +134,7 @@ pub struct DatePickerProps { #[component] pub fn DatePicker(props: DatePickerProps) -> Element { let open = use_signal(|| false); - let focus = use_focus_provider(props.roving_loop); + let focus = use_collection_provider(props.roving_loop); let available_ranges = use_memo(move || AvailableRanges::new(&props.disabled_ranges.read())); // Create context provider for child components @@ -268,7 +268,7 @@ pub struct DateRangePickerProps { #[component] pub fn DateRangePicker(props: DateRangePickerProps) -> Element { let open = use_signal(|| false); - let focus = use_focus_provider(props.roving_loop); + let focus = use_collection_provider(props.roving_loop); let available_ranges = use_memo(move || AvailableRanges::new(&props.disabled_ranges.read())); @@ -780,7 +780,8 @@ fn DateSegment( }; let disabled = move || (ctx.disabled)(); - let onmounted = use_focus_controlled_item_disabled(props.index, disabled); + let onmounted = + use_item(collection_item(ctx.focus, props.index).disabled(disabled)).onmounted(); let span_id = use_unique_id(); let id = use_memo(move || format!("span-{span_id}")); diff --git a/primitives/src/drag_and_drop_list.rs b/primitives/src/drag_and_drop_list.rs index f5d724ab0..d1534a881 100644 --- a/primitives/src/drag_and_drop_list.rs +++ b/primitives/src/drag_and_drop_list.rs @@ -1,6 +1,6 @@ //! Defines the [`DragAndDropList`] component and its sub-components. +use crate::collection::{collection_item, use_collection_provider, use_item, CollectionState}; use dioxus::prelude::*; -use std::rc::Rc; #[derive(Clone, Copy, PartialEq, Debug)] enum DropPosition { @@ -19,6 +19,29 @@ impl From for DropPosition { } } +fn sortable_item_key(children: &Element, index: usize) -> String { + children + .as_ref() + .ok() + .and_then(|vnode| vnode.key.clone()) + .unwrap_or_else(|| index.to_string()) +} + +#[derive(Clone, PartialEq)] +struct SortableListItem { + key: String, + children: Element, +} + +impl SortableListItem { + fn new(children: Element, index: usize) -> Self { + Self { + key: sortable_item_key(&children, index), + children, + } + } +} + /// Resolves the final insertion index from a hovered item and pointer position. fn resolve_drop_index(from: usize, hovered: usize, position: DropPosition) -> usize { let slot = match position { @@ -71,8 +94,8 @@ enum DragState { #[derive(Clone, Copy)] pub struct DragAndDropContext { drag: Signal, - list_items: Signal>, - focused_index: Signal>, + list_items: Signal>, + focus: CollectionState, announcement: Signal, } @@ -142,8 +165,8 @@ impl DragAndDropContext { return; }; let mut list = (self.list_items)(); - let element = list.remove(from); - list.insert(to, element); + let item = list.remove(from); + list.insert(to, item); self.list_items.set(list); self.drag.set(DragState::Dropped { from, to }); } @@ -151,11 +174,14 @@ impl DragAndDropContext { /// Remove the item at the given index from the list. pub fn remove(&mut self, index: usize) { let mut list = (self.list_items)(); - if list.remove(index).is_ok() { + if index < list.len() { + list.remove(index); let new_len = list.len(); + let focus_target = new_len.checked_sub(1).map(|last| index.min(last)); + let focus_id = + focus_target.and_then(|index| list.get(index).map(|item| item.key.clone())); self.list_items.set(list); - self.focused_index - .set(new_len.checked_sub(1).map(|last| index.min(last))); + self.focus.set_focus_key(focus_id); self.announcement.set(format!( "Removed item from position {}. {} items remaining", index + 1, @@ -173,28 +199,24 @@ impl DragAndDropContext { } fn is_focused(&self, index: usize) -> bool { - (self.focused_index)().is_some_and(|focus| focus == index) + self.focus.is_focused(index) } - fn set_focus(&mut self, id: Option) { - self.focused_index.set(id); + fn set_focus(&mut self, index: Option) { + // Every sortable item carries a key, so resolve focus by key to stay + // stable across reordering. An out-of-bounds (or `None`) index yields no + // key, which clears focus rather than pointing at an invalid slot. + let id = + index.and_then(|index| (self.list_items)().get(index).map(|item| item.key.clone())); + self.focus.set_focus_key(id); } fn focus_next(&mut self) { - let Some(index) = (self.focused_index)() else { - return; - }; - let len = (self.list_items)().len(); - self.focused_index.set(Some((index + 1) % len)); + self.focus.focus_next(); } fn focus_prev(&mut self) { - let Some(index) = (self.focused_index)() else { - return; - }; - let len = (self.list_items)().len(); - self.focused_index - .set(Some(index.checked_sub(1).unwrap_or(len - 1))); + self.focus.focus_prev(); } fn move_up(&mut self, index: usize) { @@ -330,13 +352,22 @@ pub struct DragAndDropLiveRegionProps { #[component] pub fn DragAndDropList(props: DragAndDropListProps) -> Element { let drag = use_signal(|| DragState::Idle); - let list_items = use_signal(|| props.items.clone()); + let list_items = use_signal(|| { + props + .items + .iter() + .cloned() + .enumerate() + .map(|(index, item)| SortableListItem::new(item, index)) + .collect() + }); let announcement = use_signal(String::new); + let focus = use_collection_provider(ReadSignal::new(Signal::new(true))); use_context_provider(move || DragAndDropContext { drag, list_items, - focused_index: Signal::new(None), + focus, announcement, }); @@ -370,18 +401,13 @@ pub fn use_drag_and_drop_list_items() -> Vec { (ctx.list_items)() .into_iter() .enumerate() - .map(|(index, children)| { + .map(|(index, item)| { // Propagate any `key:` the caller set on the item's root element // through to the keyed sortable item fragment. - let key = children - .as_ref() - .ok() - .and_then(|vnode| vnode.key.clone()) - .unwrap_or_else(|| index.to_string()); DragAndDropListRenderItem { index, - key, - children, + key: item.key, + children: item.children, } }) .collect() @@ -402,6 +428,7 @@ pub fn DragAndDropListItems(props: DragAndDropListItemsProps) -> Element { } DragAndDropListItem { index: item.index, + item_key: item.key.clone(), {item.children} } DragAndDropDropIndicator { @@ -472,6 +499,11 @@ pub struct DragAndDropListItemProps { /// The index of the item in the list pub index: usize, + /// Stable identity for this item. Pass the same value as the item's + /// `key:` when manually rendering sortable items. + #[props(default)] + pub item_key: Option, + /// Additional attributes to apply to the list item element. #[props(extends = GlobalAttributes)] pub attributes: Vec, @@ -540,17 +572,12 @@ pub fn DragAndDropListItem(props: DragAndDropListItemProps) -> Element { if *item_ctx.index.peek() != index { item_ctx.index.set(index); } + let index_signal = item_ctx.index; - let mut item_ref: Signal>> = use_signal(|| None); - use_effect(move || { - if ctx.is_focused(index) { - if let Some(md) = item_ref() { - spawn(async move { - let _ = md.set_focus(true).await; - }); - } - } - }); + let item_key = props.item_key.clone(); + let item = use_item(collection_item(ctx.focus, index_signal).key(move || item_key.clone())); + let mut collection_onmounted = item.onmounted(); + let mut item_ref: Signal>> = use_signal(|| None); let onkeydown = move |event: Event| { let key = event.key(); @@ -614,13 +641,11 @@ pub fn DragAndDropListItem(props: DragAndDropListItemProps) -> Element { }; }; - let is_tab_reachable = ctx.is_focused(index) || ((ctx.focused_index)().is_none() && index == 0); - rsx! { li { aria_roledescription: "sortable item", draggable: "true", - tabindex: if is_tab_reachable { "0" } else { "-1" }, + tabindex: item.tabindex, aria_grabbed: if ctx.drag_from().is_some_and(|from| from == index) { "true" } else { "false" }, "data-is-grabbing": if ctx.drag_from().is_some_and(|from| from == index) { "true" }, // Set when the drop target has returned to this item's starting slot — @@ -629,7 +654,10 @@ pub fn DragAndDropListItem(props: DragAndDropListItemProps) -> Element { // hooks off this attribute to surface the "stays here" state. "data-drop-at-origin": if ctx.drag_from().is_some_and(|from| from == index) && ctx.drop_to() == Some(index) { "true" }, "data-focus-visible": if ctx.is_focused(index) { "true" }, - onmounted: move |data| item_ref.set(Some(data.data())), + onmounted: move |event| { + item_ref.set(Some(event.data())); + collection_onmounted(event); + }, onfocus: move |_| { if !ctx.is_dragging() { ctx.set_focus(Some(index)); diff --git a/primitives/src/dropdown_menu.rs b/primitives/src/dropdown_menu.rs index 30efa41ca..bc99e1b8d 100644 --- a/primitives/src/dropdown_menu.rs +++ b/primitives/src/dropdown_menu.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use crate::{ - focus::{use_focus_controlled_item_disabled, use_focus_provider, FocusState}, + collection::{collection_item, use_collection_provider, use_item, CollectionState}, merge_attributes, use_animated_open, use_controlled, use_id_or, use_unique_id, }; use dioxus::prelude::*; @@ -17,7 +17,7 @@ struct DropdownMenuContext { disabled: ReadSignal, // Focus state - focus: FocusState, + focus: CollectionState, // Unique ID for the trigger button trigger_id: Signal, @@ -103,7 +103,7 @@ pub fn DropdownMenu(props: DropdownMenuProps) -> Element { let disabled = props.disabled; let trigger_id = use_unique_id(); - let focus = use_focus_provider(props.roving_loop); + let focus = use_collection_provider(props.roving_loop); let mut ctx = use_context_provider(|| DropdownMenuContext { open, set_open, @@ -254,7 +254,7 @@ pub fn DropdownMenuTrigger(props: DropdownMenuTriggerProps) -> Element { }, onblur: move |_| { if !ctx.focus.any_focused() { - ctx.focus.blur(); + ctx.focus.clear_focus(); ctx.set_open.call(false); } }, @@ -436,9 +436,9 @@ pub fn DropdownMenuItem( let mut ctx: DropdownMenuContext = use_context(); let disabled = move || (ctx.disabled)() || (props.disabled)(); - let focused = move || ctx.focus.is_focused((props.index)()); - - let onmounted = use_focus_controlled_item_disabled(props.index, disabled); + let item = use_item(collection_item(ctx.focus, props.index).disabled(disabled)); + let focused = move || item.focused(); + let onmounted = item.onmounted(); rsx! { div { @@ -469,7 +469,7 @@ pub fn DropdownMenuItem( onblur: move |_| { if focused() { - ctx.focus.blur(); + ctx.focus.clear_focus(); } }, diff --git a/primitives/src/focus.rs b/primitives/src/focus.rs deleted file mode 100644 index cc81263f4..000000000 --- a/primitives/src/focus.rs +++ /dev/null @@ -1,298 +0,0 @@ -use std::{collections::BTreeMap, rc::Rc}; - -use dioxus::prelude::*; - -use crate::use_effect_with_cleanup; - -pub(crate) fn use_focus_provider(roving_loop: ReadSignal) -> FocusState { - use_context_provider(|| FocusState::new(roving_loop)) -} - -pub(crate) fn use_focus_entry_disabled( - mut ctx: FocusState, - index: impl Readable + Copy + 'static, - disabled: impl Fn() -> bool + Copy + 'static, -) { - use_effect_with_cleanup(move || { - let idx = index.cloned(); - ctx.add_update_item(idx, disabled()); - move || { - ctx.remove_item(idx); - } - }); -} - -pub(crate) fn use_focus_control( - ctx: FocusState, - index: impl Readable + Copy + 'static, -) -> impl FnMut(MountedEvent) { - let disabled = || false; - use_focus_control_disabled(ctx, index, disabled) -} - -pub(crate) fn use_focus_control_disabled( - ctx: FocusState, - index: impl Readable + Copy + 'static, - disabled: impl Fn() -> bool + Copy + 'static, -) -> impl FnMut(MountedEvent) { - let mut controlled_ref: Signal>> = use_signal(|| None); - use_effect(move || { - if disabled() { - return; - } - ctx.control_mount_focus(index.cloned(), controlled_ref); - }); - - move |data: Event| controlled_ref.set(Some(data.data())) -} - -pub(crate) fn use_focus_controlled_item_disabled( - index: impl Readable + Copy + 'static, - disabled: impl Fn() -> bool + Copy + 'static, -) -> impl FnMut(MountedEvent) { - let ctx: FocusState = use_context(); - use_focus_entry_disabled(ctx, index, disabled); - use_focus_control_disabled(ctx, index, disabled) -} - -#[derive(Clone, Copy)] -pub(crate) enum FocusPlacement { - First, - Last, -} - -pub(crate) fn use_deferred_focus( - mut ctx: FocusState, - mut placement: Signal>, - active: impl Fn() -> bool + Copy + 'static, -) { - use_effect(move || { - if !active() { - placement.set(None); - return; - } - let Some(placement_value) = placement() else { - return; - }; - if ctx.try_focus_placement(placement_value) { - placement.set(None); - } - }); -} - -fn first_enabled<'a>(iter: impl IntoIterator) -> Option { - iter.into_iter() - .find_map(|(&idx, &disabled)| (!disabled).then_some(idx)) -} - -fn next_index(indices: &[usize], current: Option, roving_loop: bool) -> Option { - match current { - Some(current) => { - let next_position = indices.partition_point(|&index| index <= current); - indices - .get(next_position) - .copied() - .or_else(|| roving_loop.then(|| indices.first().copied()).flatten()) - } - None => indices.first().copied(), - } -} - -fn prev_index(indices: &[usize], current: Option, roving_loop: bool) -> Option { - match current { - Some(current) => { - let prev_position = indices.partition_point(|&index| index < current); - prev_position - .checked_sub(1) - .and_then(|position| indices.get(position).copied()) - .or_else(|| roving_loop.then(|| indices.last().copied()).flatten()) - } - None if roving_loop => indices.last().copied(), - None => indices.first().copied(), - } -} - -#[derive(Clone, Copy)] -pub(crate) struct FocusState { - pub(crate) roving_loop: ReadSignal, - pub(crate) recent_focus: Signal>, - pub(crate) current_focus: Signal>, - items: Signal>, -} - -impl FocusState { - pub(crate) fn new(roving_loop: ReadSignal) -> Self { - Self { - roving_loop, - recent_focus: Signal::new(None), - current_focus: Signal::new(None), - items: Signal::new(BTreeMap::new()), - } - } - - pub(crate) fn set_focus(&mut self, index: Option) { - // Resolve the effective target: an index pointing at a disabled item - // collapses to None. - let target = match index { - Some(idx) if self.items.peek().get(&idx) == Some(&true) => None, - other => other, - }; - if let Some(idx) = target { - self.recent_focus.set(Some(idx)); - } - // Only notify subscribers when the value actually changes. Otherwise - // a redundant `blur()` call (with nothing focused) still wakes any - // effect reading `any_focused()`, which on Brave/Android caused the - // context menu to auto-close: the menu container's onblur fires - // shortly after open, calls blur(), and the focus-sync effect then - // sees `open && !any_focused` and closes the menu. - if *self.current_focus.peek() != target { - self.current_focus.set(target); - } - } - - pub(crate) fn first_enabled_index(&self) -> Option { - first_enabled(self.items.read().iter()) - } - - pub(crate) fn last_enabled_index(&self) -> Option { - first_enabled(self.items.read().iter().rev()) - } - - fn enabled_indices(&self) -> Vec { - self.items - .read() - .iter() - .filter_map(|(&index, &disabled)| (!disabled).then_some(index)) - .collect() - } - - fn focus_next_from(&mut self, current: Option, indices: &[usize]) { - self.set_focus(next_index(indices, current, (self.roving_loop)())); - } - - fn focus_prev_from(&mut self, current: Option, indices: &[usize]) { - self.set_focus(prev_index(indices, current, (self.roving_loop)())); - } - - pub(crate) fn focus_next(&mut self) { - let indices = self.enabled_indices(); - self.focus_next_from(self.recent_focus(), &indices); - } - - pub(crate) fn focus_prev(&mut self) { - let indices = self.enabled_indices(); - self.focus_prev_from(self.recent_focus(), &indices); - } - - pub(crate) fn focus_first(&mut self) { - self.set_focus(self.first_enabled_index()); - } - - pub(crate) fn focus_last(&mut self) { - self.set_focus(self.last_enabled_index()); - } - - pub(crate) fn focus_next_from_current(&mut self, indices: &[usize]) { - self.focus_next_from(self.current_focus(), indices); - } - - pub(crate) fn focus_prev_from_current(&mut self, indices: &[usize]) { - self.focus_prev_from(self.current_focus(), indices); - } - - pub(crate) fn try_focus_placement(&mut self, placement: FocusPlacement) -> bool { - let Some(index) = (match placement { - FocusPlacement::First => self.first_enabled_index(), - FocusPlacement::Last => self.last_enabled_index(), - }) else { - return false; - }; - self.set_focus(Some(index)); - true - } - - pub(crate) fn blur(&mut self) { - self.set_focus(None); - } - - pub(crate) fn any_focused(&self) -> bool { - self.current_focus.read().is_some() - } - - pub(crate) fn is_focused(&self, id: usize) -> bool { - (self.current_focus)().map(|x| x == id).unwrap_or(false) - } - - pub(crate) fn current_focus(&self) -> Option { - (self.current_focus)() - } - - pub(crate) fn recent_focus(&self) -> Option { - (self.recent_focus)() - } - - pub(crate) fn recent_focus_or_default(&self) -> usize { - self.recent_focus() - .filter(|&index| self.is_enabled(index)) - .or_else(|| self.first_enabled_index()) - .unwrap_or_default() - } - - pub(crate) fn is_enabled(&self, index: usize) -> bool { - self.items.peek().get(&index) == Some(&false) - } - - /// Pick the next enabled item after `from`, wrapping when roving_loop is on. - /// Used to redirect focus that's parked on a known-disabled item. - fn next_focus_skipping(&self, from: usize) -> Option { - let items = self.items.peek(); - first_enabled(items.range(from.saturating_add(1)..)).or_else(|| { - self.roving_loop - .peek() - .then(|| first_enabled(items.iter())) - .flatten() - }) - } - - pub(crate) fn add_update_item(&mut self, index: usize, disabled: bool) { - if self.items.peek().get(&index) == Some(&disabled) { - return; - } - self.items.write().insert(index, disabled); - - let Some(focused) = *self.current_focus.peek() else { - return; - }; - if disabled && focused == index { - // Focus cannot remain on a disabled item. - self.blur(); - } else if !disabled && self.items.peek().get(&focused) == Some(&true) { - // Focus is parked on a known-disabled item; advance to the nearest enabled one. - if let Some(next) = self.next_focus_skipping(focused) { - self.set_focus(Some(next)); - } - } - } - - pub(crate) fn remove_item(&mut self, index: usize) { - let removed = self.items.write().remove(&index).is_some(); - if removed && (self.current_focus)() == Some(index) { - self.set_focus(None); - } - } - - pub(crate) fn control_mount_focus( - &self, - index: usize, - controlled_ref: Signal>>, - ) { - if self.is_focused(index) && self.is_enabled(index) { - if let Some(md) = controlled_ref() { - spawn(async move { - let _ = md.set_focus(true).await; - }); - } - } - } -} diff --git a/primitives/src/lib.rs b/primitives/src/lib.rs index facfc1e63..1eb60c9bf 100644 --- a/primitives/src/lib.rs +++ b/primitives/src/lib.rs @@ -20,6 +20,7 @@ pub mod avatar; pub mod calendar; pub mod checkbox; pub mod collapsible; +mod collection; pub mod color_picker; pub mod combobox; pub mod context_menu; @@ -27,7 +28,6 @@ pub mod date_picker; pub mod dialog; pub mod drag_and_drop_list; pub mod dropdown_menu; -mod focus; pub mod hover_card; pub mod label; mod listbox; @@ -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/listbox.rs b/primitives/src/listbox.rs index 6ef68db4c..e67be6329 100644 --- a/primitives/src/listbox.rs +++ b/primitives/src/listbox.rs @@ -63,7 +63,7 @@ pub(crate) fn use_listbox_container( } if let Some(index) = selectable.initial_focus.cloned() { - selectable.focus_state.set_focus(Some(index)); + selectable.collection.set_focus(Some(index)); selectable.initial_focus.set(None); } }); @@ -77,7 +77,6 @@ pub(crate) fn use_listbox_option( value: ReadSignal, text_value: ReadSignal>, options: Signal>, - disabled: impl Fn() -> bool + Copy + 'static, component_name: &'static str, ) -> Memo { let generated_id = use_unique_id(); @@ -88,30 +87,27 @@ pub(crate) fn use_listbox_option( use_effect(move || { let option_id = id(); - let stale_id = previous_id - .peek() - .as_ref() - .filter(|stale_id| *stale_id != &option_id) - .cloned(); + let option_index = index(); + let stale_id = previous_id.peek().clone(); if let Some(stale_id) = stale_id { - remove_option(options, &stale_id); + if stale_id != option_id { + remove_option(options, &stale_id); + } } - let registered_id = option_id.clone(); sync_option( options, OptionState { - tab_index: index(), + id: option_id.clone(), + index: option_index, value: RcPartialEqValue::new(value.cloned()), text_value: text_value.cloned(), - id: registered_id, - disabled: disabled(), }, ); previous_id.set(Some(option_id)); }); use_effect_cleanup(move || { - if let Some(option_id) = previous_id.peek().as_ref() { + if let Some(option_id) = previous_id.peek().as_deref() { remove_option(options, option_id); } }); diff --git a/primitives/src/menubar.rs b/primitives/src/menubar.rs index d60bfb666..ad9ca38c7 100644 --- a/primitives/src/menubar.rs +++ b/primitives/src/menubar.rs @@ -3,9 +3,9 @@ use dioxus::prelude::*; use crate::{ - focus::{ - use_deferred_focus, use_focus_control, use_focus_controlled_item_disabled, - use_focus_entry_disabled, use_focus_provider, FocusPlacement, FocusState, + collection::{ + collection_item, use_collection_provider, use_deferred_collection_focus, use_item, + CollectionPlacement, CollectionState, }, use_animated_open, use_id_or, use_unique_id, }; @@ -18,7 +18,7 @@ struct MenubarContext { disabled: ReadSignal, // Focus state - focus: FocusState, + focus: CollectionState, } /// The props for the [`Menubar`] component. @@ -111,7 +111,7 @@ pub fn Menubar(props: MenubarProps) -> Element { let mut open_menu = use_signal(|| None); let set_open_menu = use_callback(move |idx| open_menu.set(idx)); - let focus = use_focus_provider(props.roving_loop); + let focus = use_collection_provider(props.roving_loop); let mut ctx = use_context_provider(|| MenubarContext { open_menu, set_open_menu, @@ -119,7 +119,7 @@ pub fn Menubar(props: MenubarProps) -> Element { focus, }); use_effect(move || { - let index = ctx.focus.current_focus(); + let index = ctx.focus.focused_index(); if ctx.open_menu.peek().is_some() { ctx.set_open_menu.call(index); } @@ -130,7 +130,7 @@ pub fn Menubar(props: MenubarProps) -> Element { role: "menubar", "data-disabled": (props.disabled)(), tabindex: (!ctx.focus.any_focused()).then_some("0"), - // If the menu receives focus, focus the most recently focused menu item + // If the menu receives focus, focus the most recently focused menu item. onfocus: move |_| { ctx.focus.set_focus(Some(ctx.focus.recent_focus_or_default())); }, @@ -145,10 +145,10 @@ pub fn Menubar(props: MenubarProps) -> Element { #[derive(Clone, Copy)] struct MenubarMenuContext { index: ReadSignal, - focus: FocusState, + focus: CollectionState, is_open: Memo, disabled: ReadSignal, - initial_focus: Signal>, + initial_focus: Signal>, } impl MenubarMenuContext { @@ -253,8 +253,10 @@ pub struct MenubarMenuProps { pub fn MenubarMenu(props: MenubarMenuProps) -> Element { let mut ctx: MenubarContext = use_context(); let is_open = use_memo(move || (ctx.open_menu)() == Some(props.index.cloned())); - let focus = use_focus_provider(ctx.focus.roving_loop); + let focus = use_collection_provider(ctx.focus.loop_signal()); let initial_focus = use_signal(|| None); + let disabled = move || (ctx.disabled)() || (props.disabled)(); + let mut menu_ctx = use_context_provider(|| MenubarMenuContext { index: props.index, focus, @@ -265,14 +267,11 @@ pub fn MenubarMenu(props: MenubarMenuProps) -> Element { use_effect(move || { if !is_open() { - menu_ctx.focus.blur(); + menu_ctx.focus.clear_focus(); menu_ctx.initial_focus.set(None); } }); - let disabled = move || (ctx.disabled)() || (props.disabled)(); - use_focus_entry_disabled(ctx.focus, menu_ctx.index, disabled); - rsx! { div { role: "menu", @@ -289,7 +288,7 @@ pub fn MenubarMenu(props: MenubarMenuProps) -> Element { Key::ArrowRight => ctx.focus.focus_next(), Key::ArrowDown if !disabled() => { if !is_open() { - menu_ctx.initial_focus.set(Some(FocusPlacement::First)); + menu_ctx.initial_focus.set(Some(CollectionPlacement::First)); ctx.set_open_menu.call(Some(props.index.cloned())); } else { menu_ctx.focus_next(); @@ -299,7 +298,7 @@ pub fn MenubarMenu(props: MenubarMenuProps) -> Element { if is_open() { menu_ctx.focus_prev(); } else { - menu_ctx.initial_focus.set(Some(FocusPlacement::Last)); + menu_ctx.initial_focus.set(Some(CollectionPlacement::Last)); ctx.set_open_menu.call(Some(props.index.cloned())); } }, @@ -393,13 +392,14 @@ pub struct MenubarTriggerProps { pub fn MenubarTrigger(props: MenubarTriggerProps) -> Element { let mut ctx: MenubarContext = use_context(); let menu_ctx: MenubarMenuContext = use_context(); - let onmounted = use_focus_control(ctx.focus, menu_ctx.index); let disabled = move || (ctx.disabled)() || (menu_ctx.disabled)(); + // The menu's trigger is the focusable element, so it registers the menu in + // the parent menubar collection. + let item = use_item(collection_item(ctx.focus, menu_ctx.index).disabled(disabled)); + let onmounted = item.onmounted(); let is_open = menu_ctx.is_open; let index = menu_ctx.index; - let is_focused = move || { - ctx.focus.current_focus() == Some(menu_ctx.index.cloned()) && !menu_ctx.focus.any_focused() - }; + let is_focused = move || item.focused() && !menu_ctx.focus.any_focused(); rsx! { button { @@ -418,7 +418,7 @@ pub fn MenubarTrigger(props: MenubarTriggerProps) -> Element { }, onblur: move |_| { if is_focused() { - ctx.focus.set_focus(None); + ctx.focus.clear_focus(); ctx.set_open_menu.call(None); } }, @@ -519,7 +519,7 @@ pub fn MenubarContent(props: MenubarContentProps) -> Element { let id = use_id_or(unique_id, props.id); let render = use_animated_open(id, menu_ctx.is_open); - use_deferred_focus(menu_ctx.focus, menu_ctx.initial_focus, render); + use_deferred_collection_focus(menu_ctx.focus, menu_ctx.initial_focus, render); rsx! { if render() { @@ -634,9 +634,10 @@ pub fn MenubarItem(props: MenubarItemProps) -> Element { let mut menu_ctx: MenubarMenuContext = use_context(); let disabled = move || (ctx.disabled)() || (props.disabled)(); - let focused = move || menu_ctx.focus.is_focused(props.index.cloned()) && (menu_ctx.is_open)(); + let item = use_item(collection_item(menu_ctx.focus, props.index).disabled(disabled)); + let focused = move || item.focused() && (menu_ctx.is_open)(); - let onmounted = use_focus_controlled_item_disabled(props.index, disabled); + let onmounted = item.onmounted(); rsx! { div { @@ -672,8 +673,8 @@ pub fn MenubarItem(props: MenubarItemProps) -> Element { onblur: move |_| { if focused() { - menu_ctx.focus.blur(); - ctx.focus.set_focus(None); + menu_ctx.focus.clear_focus(); + ctx.focus.clear_focus(); ctx.set_open_menu.call(None); } }, diff --git a/primitives/src/navbar.rs b/primitives/src/navbar.rs index bbb0f183c..c2a545b91 100644 --- a/primitives/src/navbar.rs +++ b/primitives/src/navbar.rs @@ -1,9 +1,9 @@ //! Defines the [`Navbar`] component and its sub-components. use crate::{ - focus::{ - use_deferred_focus, use_focus_control, use_focus_controlled_item_disabled, - use_focus_entry_disabled, use_focus_provider, FocusPlacement, FocusState, + collection::{ + collection_item, use_collection_provider, use_deferred_collection_focus, use_item, + CollectionPlacement, CollectionState, }, use_animated_open, use_id_or, use_unique_id, }; @@ -17,7 +17,7 @@ struct NavbarContext { disabled: ReadSignal, // Focus state - focus: FocusState, + focus: CollectionState, } /// The props for the [`Navbar`] component. @@ -111,7 +111,7 @@ pub fn Navbar(props: NavbarProps) -> Element { let mut open_nav = use_signal(|| None); let set_open_nav = use_callback(move |idx| open_nav.set(idx)); - let focus = use_focus_provider(props.roving_loop); + let focus = use_collection_provider(props.roving_loop); let mut ctx = use_context_provider(|| NavbarContext { open_nav, set_open_nav, @@ -119,7 +119,7 @@ pub fn Navbar(props: NavbarProps) -> Element { focus, }); use_effect(move || { - let index = ctx.focus.current_focus(); + let index = ctx.focus.focused_index(); if ctx.open_nav.peek().is_some() { ctx.set_open_nav.call(index); } @@ -139,7 +139,7 @@ pub fn Navbar(props: NavbarProps) -> Element { role: "menubar", "data-disabled": (props.disabled)(), tabindex: (!ctx.focus.any_focused()).then_some("0"), - // If the menu receives focus, focus the most recently focused menu item + // If the menu receives focus, focus the most recently focused menu item. onfocus: move |_| { ctx.focus.set_focus(Some(ctx.focus.recent_focus_or_default())); }, @@ -166,10 +166,10 @@ pub fn Navbar(props: NavbarProps) -> Element { #[derive(Clone, Copy)] struct NavbarNavContext { index: ReadSignal, - focus: FocusState, + focus: CollectionState, is_open: Memo, disabled: ReadSignal, - initial_focus: Signal>, + initial_focus: Signal>, } impl NavbarNavContext { @@ -274,8 +274,11 @@ pub struct NavbarNavProps { pub fn NavbarNav(props: NavbarNavProps) -> Element { let mut ctx: NavbarContext = use_context(); let is_open = use_memo(move || (ctx.open_nav)() == Some(props.index.cloned())); - let focus = use_focus_provider(ctx.focus.roving_loop); + let focus = use_collection_provider(ctx.focus.loop_signal()); let initial_focus = use_signal(|| None); + + let disabled = move || (ctx.disabled)() || (props.disabled)(); + let mut nav_ctx = use_context_provider(|| NavbarNavContext { index: props.index, focus, @@ -286,14 +289,11 @@ pub fn NavbarNav(props: NavbarNavProps) -> Element { use_effect(move || { if !is_open() { - nav_ctx.focus.blur(); + nav_ctx.focus.clear_focus(); nav_ctx.initial_focus.set(None); } }); - let disabled = move || (ctx.disabled)() || (props.disabled)(); - use_focus_entry_disabled(ctx.focus, nav_ctx.index, disabled); - rsx! { div { role: "menu", @@ -312,7 +312,7 @@ pub fn NavbarNav(props: NavbarNavProps) -> Element { }, onmouseleave: move |_| { if is_open() { - ctx.focus.set_focus(None); + ctx.focus.clear_focus(); } }, onkeydown: move |event: Event| { @@ -322,7 +322,7 @@ pub fn NavbarNav(props: NavbarNavProps) -> Element { } Key::ArrowDown if !disabled() => { if !is_open() { - nav_ctx.initial_focus.set(Some(FocusPlacement::First)); + nav_ctx.initial_focus.set(Some(CollectionPlacement::First)); ctx.set_open_nav.call(Some(props.index.cloned())); } else { nav_ctx.focus_next(); @@ -332,7 +332,7 @@ pub fn NavbarNav(props: NavbarNavProps) -> Element { if is_open() { nav_ctx.focus_prev(); } else { - nav_ctx.initial_focus.set(Some(FocusPlacement::Last)); + nav_ctx.initial_focus.set(Some(CollectionPlacement::Last)); ctx.set_open_nav.call(Some(props.index.cloned())); } }, @@ -426,11 +426,12 @@ pub struct NavbarTriggerProps { pub fn NavbarTrigger(props: NavbarTriggerProps) -> Element { let mut ctx: NavbarContext = use_context(); let nav_ctx: NavbarNavContext = use_context(); - let onmounted = use_focus_control(ctx.focus, nav_ctx.index); - let is_focused = move || { - ctx.focus.current_focus() == Some(nav_ctx.index.cloned()) && !nav_ctx.focus.any_focused() - }; let disabled = move || (ctx.disabled)() || (nav_ctx.disabled)(); + // The nav's trigger is the focusable element, so it registers the nav in the + // parent navbar collection. + let item = use_item(collection_item(ctx.focus, nav_ctx.index).disabled(disabled)); + let is_focused = move || item.focused() && !nav_ctx.focus.any_focused(); + let onmounted = item.onmounted(); let is_open = nav_ctx.is_open; rsx! { @@ -449,7 +450,7 @@ pub fn NavbarTrigger(props: NavbarTriggerProps) -> Element { }, onblur: move |_| { if is_focused() { - ctx.focus.set_focus(None); + ctx.focus.clear_focus(); ctx.set_open_nav.call(None); } }, @@ -562,7 +563,7 @@ pub fn NavbarContent(props: NavbarContentProps) -> Element { let id = use_id_or(unique_id, props.id); let render = use_animated_open(id, nav_ctx.is_open); - use_deferred_focus(nav_ctx.focus, nav_ctx.initial_focus, render); + use_deferred_collection_focus(nav_ctx.focus, nav_ctx.initial_focus, render); rsx! { if render() { @@ -730,7 +731,11 @@ pub fn NavbarItem(mut props: NavbarItemProps) -> Element { ) }; - let mut onmounted = use_focus_controlled_item_disabled(props.index, disabled); + // Resolve the innermost collection: the parent nav's when inside a + // NavbarNav, otherwise the top-level navbar's. + let collection: CollectionState = use_context(); + let mut onmounted = + use_item(collection_item(collection, props.index).disabled(disabled)).onmounted(); props.attributes.push(onkeydown({ let value = props.value.clone(); @@ -760,9 +765,9 @@ pub fn NavbarItem(mut props: NavbarItemProps) -> Element { props.attributes.push(onblur(move |_| { if focused() { if let Some(nav_ctx) = &mut nav_ctx { - nav_ctx.focus.blur(); + nav_ctx.focus.clear_focus(); } - ctx.focus.set_focus(None); + ctx.focus.clear_focus(); ctx.set_open_nav.call(None); } })); diff --git a/primitives/src/radio_group.rs b/primitives/src/radio_group.rs index ea9a2fb73..2e6c4f715 100644 --- a/primitives/src/radio_group.rs +++ b/primitives/src/radio_group.rs @@ -3,7 +3,9 @@ use std::collections::HashMap; use crate::{ - focus::{use_focus_controlled_item_disabled, use_focus_provider, FocusState}, + collection::{ + collection_item, use_collection_provider_with, use_item, CollectionOptions, CollectionState, + }, use_controlled, use_effect_with_cleanup, }; use dioxus::prelude::*; @@ -18,10 +20,9 @@ struct RadioGroupCtx { // Keyboard nav data // A map of tabindex -> value in the enabled radio items values: Signal>, - focus: FocusState, + focus: CollectionState, horizontal: ReadSignal, - roving_loop: ReadSignal, } impl RadioGroupCtx { @@ -52,7 +53,7 @@ impl RadioGroupCtx { } fn select_focused_value(&mut self) { - if let Some(current_focus) = self.focus.current_focus() { + if let Some(current_focus) = self.focus.focused_index() { let value = { self.values.read().get(¤t_focus).cloned() }; if let Some(value) = value { self.set_value(value.clone()); @@ -156,7 +157,14 @@ pub fn RadioGroup(props: RadioGroupProps) -> Element { let (value, set_value) = use_controlled(props.value, props.default_value, props.on_value_change); - let focus = use_focus_provider(props.roving_loop); + // Native radio-group semantics: when nothing is selected, every radio stays + // in the tab order until one is focused or checked. + let focus = use_collection_provider_with( + props.roving_loop, + CollectionOptions { + tabbable_when_empty: true, + }, + ); let mut ctx = use_context_provider(|| RadioGroupCtx { value, set_value, @@ -165,7 +173,6 @@ pub fn RadioGroup(props: RadioGroupProps) -> Element { values: Signal::new(Default::default()), focus, horizontal: props.horizontal, - roving_loop: props.roving_loop, }); rsx! { @@ -270,28 +277,13 @@ pub fn RadioItem(props: RadioItemProps) -> Element { let value = (props.value)().clone(); let checked = use_memo(move || (ctx.value)() == value); - // Tab index for roving index - let tab_index = use_memo(move || { - if !(ctx.roving_loop)() { - return "0"; - } - - if checked() { - return "0"; - } - let current_focus = ctx.focus.current_focus(); - if let Some(current_focus) = current_focus { - if current_focus == (props.index)() { - return "0"; - } - } else if (ctx.value)().is_empty() { - return "0"; - } - - "-1" - }); - - let onmounted = use_focus_controlled_item_disabled(props.index, disabled); + let item = use_item( + collection_item(ctx.focus, props.index) + .disabled(disabled) + .selected(move || checked.cloned()), + ); + let tab_index = item.tabindex; + let onmounted = item.onmounted(); rsx! { button { diff --git a/primitives/src/select/components/list.rs b/primitives/src/select/components/list.rs index b04b6426d..a1ed5b439 100644 --- a/primitives/src/select/components/list.rs +++ b/primitives/src/select/components/list.rs @@ -72,7 +72,7 @@ pub fn SelectList(props: SelectListProps) -> Element { let open = ctx.selectable.open; let mut listbox_ref: Signal>> = use_signal(|| None); - let focused = move || open() && !ctx.selectable.focus_state.any_focused(); + let focused = move || open() && !ctx.selectable.collection.any_focused(); use_effect(move || { let Some(listbox_ref) = listbox_ref() else { @@ -116,19 +116,19 @@ pub fn SelectList(props: SelectListProps) -> Element { } Key::ArrowUp => { arrow_key_navigation(event); - ctx.selectable.focus_state.focus_prev(); + ctx.selectable.collection.focus_prev(); } Key::End => { arrow_key_navigation(event); - ctx.selectable.focus_state.focus_last(); + ctx.selectable.collection.focus_last(); } Key::ArrowDown => { arrow_key_navigation(event); - ctx.selectable.focus_state.focus_next(); + ctx.selectable.collection.focus_next(); } Key::Home => { arrow_key_navigation(event); - ctx.selectable.focus_state.focus_first(); + ctx.selectable.collection.focus_first(); } Key::Enter => { ctx.select_current_item(); diff --git a/primitives/src/select/components/option.rs b/primitives/src/select/components/option.rs index 73ba08339..38ef68c3b 100644 --- a/primitives/src/select/components/option.rs +++ b/primitives/src/select/components/option.rs @@ -1,7 +1,7 @@ //! SelectOption and SelectItemIndicator component implementations. use crate::{ - focus::use_focus_control_disabled, + collection::{collection_item, use_item}, listbox::{ListboxContext, ListboxItemIndicator}, selectable::{ pointer_select_cancel, pointer_select_commit, pointer_select_start, use_selectable_option, @@ -117,10 +117,13 @@ pub fn SelectOption(props: SelectOptionProps) }, ); - let onmounted = - use_focus_control_disabled(ctx.selectable.focus_state, props.index, move || { - option.disabled.cloned() - }); + let item = use_item( + collection_item(ctx.selectable.collection, props.index) + .key(move || Some(option.id.cloned())) + .disabled(move || option.disabled.cloned()) + .selected(move || (option.selected)()), + ); + let onmounted = item.onmounted(); let render = use_context::().render; @@ -151,7 +154,7 @@ pub fn SelectOption(props: SelectOptionProps) }, onblur: move |_| { if (option.focused)() { - ctx.selectable.focus_state.blur(); + ctx.selectable.collection.clear_focus(); ctx.set_open(false); } }, diff --git a/primitives/src/select/components/trigger.rs b/primitives/src/select/components/trigger.rs index f1e5b0e30..2b53e3df5 100644 --- a/primitives/src/select/components/trigger.rs +++ b/primitives/src/select/components/trigger.rs @@ -75,7 +75,7 @@ pub fn SelectTrigger(props: SelectTriggerProps) -> Element { type: "button", onclick: move |_| { - ctx.selectable.toggle_open(); + ctx.set_open(!open()); }, onkeydown: move |event| { match event.key() { @@ -83,7 +83,7 @@ pub fn SelectTrigger(props: SelectTriggerProps) -> Element { ctx.set_open(true); ctx.selectable .initial_focus - .set(ctx.selectable.focus_state.last_enabled_index()); + .set(ctx.selectable.collection.last_available_index()); event.prevent_default(); event.stop_propagation(); } @@ -91,7 +91,7 @@ pub fn SelectTrigger(props: SelectTriggerProps) -> Element { ctx.set_open(true); ctx.selectable .initial_focus - .set(ctx.selectable.focus_state.first_enabled_index()); + .set(ctx.selectable.collection.first_available_index()); event.prevent_default(); event.stop_propagation(); } diff --git a/primitives/src/select/context.rs b/primitives/src/select/context.rs index 7bad46489..61211ecb1 100644 --- a/primitives/src/select/context.rs +++ b/primitives/src/select/context.rs @@ -83,11 +83,11 @@ impl SelectContext { let keyboard = self.adaptive_keyboard.read(); if let Some(best_match_index) = - super::text_search::best_match(&keyboard, &typeahead, &options) + super::text_search::best_match(&keyboard, &typeahead, &options, |index| { + self.selectable.collection.is_available(index) + }) { - self.selectable - .focus_state - .set_focus(Some(best_match_index)); + self.selectable.collection.set_focus(Some(best_match_index)); } } } diff --git a/primitives/src/select/text_search.rs b/primitives/src/select/text_search.rs index 8f4d90096..7a69a37b5 100644 --- a/primitives/src/select/text_search.rs +++ b/primitives/src/select/text_search.rs @@ -9,6 +9,7 @@ pub(super) fn best_match( keyboard: &AdaptiveKeyboard, typeahead: &str, options: &[OptionState], + is_available: impl Fn(usize) -> bool, ) -> Option { if typeahead.is_empty() { return None; @@ -18,12 +19,12 @@ pub(super) fn best_match( options .iter() - .filter(|opt| !opt.disabled) + .filter(|opt| is_available(opt.index)) .map(|opt| { let value = &opt.text_value; let value_characters: Box<[_]> = value.chars().collect(); let distance = normalized_distance(&typeahead_characters, &value_characters, keyboard); - (distance, opt.tab_index) + (distance, opt.index) }) .min_by(|(d1, _), (d2, _)| f32::total_cmp(d1, d2)) .map(|(_, value)| value) @@ -421,6 +422,15 @@ mod tests { use crate::selectable::{OptionState, RcPartialEqValue}; use std::collections::HashMap; + fn option(index: usize, value: &'static str, text_value: &str) -> OptionState { + OptionState { + id: format!("option-{index}"), + index, + value: RcPartialEqValue::new(value), + text_value: text_value.to_string(), + } + } + #[test] fn test_levenshtein_distance() { let typeahead = ['a', 'b', 'c']; @@ -535,45 +545,27 @@ mod tests { #[test] fn test_best_match() { let options = vec![ - OptionState { - tab_index: 0, - value: RcPartialEqValue::new("apple"), - text_value: "Apple".to_string(), - id: "apple".to_string(), - disabled: false, - }, - OptionState { - tab_index: 1, - value: RcPartialEqValue::new("banana"), - text_value: "Banana".to_string(), - id: "banana".to_string(), - disabled: false, - }, - OptionState { - tab_index: 2, - value: RcPartialEqValue::new("cherry"), - text_value: "Cherry".to_string(), - id: "cherry".to_string(), - disabled: false, - }, + option(0, "apple", "Apple"), + option(1, "banana", "Banana"), + option(2, "cherry", "Cherry"), ]; let layout = AdaptiveKeyboard::default(); // Exact prefix match - let result = best_match(&layout, "App", &options); + let result = best_match(&layout, "App", &options, |_| true); assert_eq!(result, Some(0)); // Partial match - let result = best_match(&layout, "ban", &options); + let result = best_match(&layout, "ban", &options, |_| true); assert_eq!(result, Some(1)); // Empty typeahead should return None - let result = best_match(&layout, "", &options); + let result = best_match(&layout, "", &options, |_| true); assert_eq!(result, None); // No match should return closest option - let result = best_match(&layout, "xyz", &options); + let result = best_match(&layout, "xyz", &options, |_| true); assert!(result.is_some()); } @@ -603,29 +595,14 @@ mod tests { assert_eq!(adaptive.physical_mappings.get("KeyA"), Some(&'ф')); assert_eq!(adaptive.physical_mappings.get("KeyS"), Some(&'ы')); - let options = vec![ - OptionState { - tab_index: 0, - value: RcPartialEqValue::new("ф"), - text_value: "ф".to_string(), - id: "ф".to_string(), - disabled: false, - }, - OptionState { - tab_index: 1, - value: RcPartialEqValue::new("banana"), - text_value: "Banana".to_string(), - id: "banana".to_string(), - disabled: false, - }, - ]; + let options = vec![option(0, "ф", "ф"), option(1, "banana", "Banana")]; // ы should be a closer match to ф than banana - let result = best_match(&adaptive, "ф", &options); + let result = best_match(&adaptive, "ф", &options, |_| true); assert_eq!(result, Some(0)); // b should still match banana - let result = best_match(&adaptive, "b", &options); + let result = best_match(&adaptive, "b", &options, |_| true); assert_eq!(result, Some(1)); } diff --git a/primitives/src/selectable.rs b/primitives/src/selectable.rs index 51eecf5c5..c815ab668 100644 --- a/primitives/src/selectable.rs +++ b/primitives/src/selectable.rs @@ -4,7 +4,7 @@ use dioxus::html::input_data::MouseButton; use dioxus::prelude::*; use crate::{ - focus::{use_focus_entry_disabled, use_focus_provider, FocusState}, + collection::{use_collection_provider, CollectionState}, listbox::{use_listbox_option, ListboxOptionContext}, selection, use_controlled, Controlled, }; @@ -40,7 +40,7 @@ pub(crate) struct SelectableContext { pub(crate) selection_mode: SelectionMode, pub(crate) options: Signal>, pub(crate) list_id: Signal>, - pub(crate) focus_state: FocusState, + pub(crate) collection: CollectionState, pub(crate) initial_focus: Signal>, pub(crate) disabled: ReadSignal, } @@ -70,10 +70,6 @@ impl SelectableContext { self.set_open.call(open); } - pub(crate) fn toggle_open(&mut self) { - self.set_open(!self.open.cloned()); - } - pub(crate) fn selected_text(&self) -> Option { let values = self.values.read(); let options = self.options.read(); @@ -89,32 +85,24 @@ impl SelectableContext { } pub(crate) fn focused_option_id(&self) -> Option { - let index = self.focus_state.current_focus()?; - if !self.focus_state.is_enabled(index) { - return None; - } - self.options - .read() - .iter() - .find(|option| option.tab_index == index && !option.disabled) - .map(|option| option.id.clone()) + self.collection.focused_key() } pub(crate) fn select_focused(&mut self) { if !self.open.cloned() { return; } - let Some(index) = self.focus_state.current_focus() else { + let Some(index) = self.collection.focused_index() else { return; }; - if !self.focus_state.is_enabled(index) { + if !self.collection.is_available(index) { return; } let value = self .options .read() .iter() - .find(|option| option.tab_index == index && !option.disabled) + .find(|option| option.index == index) .map(|option| option.value.clone()); if let Some(value) = value { self.select_value(value); @@ -126,8 +114,8 @@ impl SelectableContext { .options .read() .iter() - .filter(|option| !option.disabled && predicate(option)) - .map(|option| option.tab_index) + .filter(|option| self.collection.is_available(option.index) && predicate(option)) + .map(|option| option.index) .collect(); indices.sort_unstable(); indices.dedup(); @@ -149,23 +137,33 @@ impl SelectableContext { } pub(crate) fn focus_next_where(&mut self, predicate: impl Fn(&OptionState) -> bool) { - let indices = self.matching_enabled_indices(predicate); - self.focus_state.focus_next_from_current(&indices); + let options = self.options; + self.collection.focus_next_matching(move |index| { + options + .read() + .iter() + .any(|option| option.index == index && predicate(option)) + }); } pub(crate) fn focus_prev_where(&mut self, predicate: impl Fn(&OptionState) -> bool) { - let indices = self.matching_enabled_indices(predicate); - self.focus_state.focus_prev_from_current(&indices); + let options = self.options; + self.collection.focus_prev_matching(move |index| { + options + .read() + .iter() + .any(|option| option.index == index && predicate(option)) + }); } pub(crate) fn focus_first_where(&mut self, predicate: impl Fn(&OptionState) -> bool) { let index = self.first_matching_enabled_index(predicate); - self.focus_state.set_focus(index); + self.collection.set_focus(index); } pub(crate) fn focus_last_where(&mut self, predicate: impl Fn(&OptionState) -> bool) { let index = self.last_matching_enabled_index(predicate); - self.focus_state.set_focus(index); + self.collection.set_focus(index); } pub(crate) fn select_value(&mut self, value: RcPartialEqValue) { @@ -211,7 +209,7 @@ pub(crate) fn use_selectable_root( let (open, set_open) = use_controlled(open.value, open.default.cloned(), open.on_change); let options: Signal> = use_signal(Vec::default); let list_id = use_signal(|| None); - let focus_state = use_focus_provider(roving_loop); + let collection = use_collection_provider(roving_loop); let initial_focus = use_signal(|| None); SelectableContext { @@ -222,7 +220,7 @@ pub(crate) fn use_selectable_root( selection_mode, options, list_id, - focus_state, + collection, initial_focus, disabled, } @@ -250,12 +248,10 @@ pub(crate) fn use_selectable_option( value, text_value, selectable.options, - move || disabled.cloned(), component_name, ); - use_focus_entry_disabled(selectable.focus_state, index, move || disabled.cloned()); let selected = use_memo(move || selectable.is_selected(&RcPartialEqValue::new(value.cloned()))); - let focused = use_memo(move || selectable.focus_state.is_focused(index())); + let focused = use_memo(move || selectable.collection.is_focused(index())); let down_pos: Signal> = use_signal(|| None); use_context_provider(|| ListboxOptionContext { diff --git a/primitives/src/selection.rs b/primitives/src/selection.rs index 61134e3cb..fe8a8a3c2 100644 --- a/primitives/src/selection.rs +++ b/primitives/src/selection.rs @@ -47,16 +47,14 @@ impl PartialEq for RcPartialEqValue { /// Registered option metadata shared by select-like components. #[derive(PartialEq)] pub(crate) struct OptionState { - /// Focus/navigation index. - pub(crate) tab_index: usize, + /// Stable option identity. + pub(crate) id: String, + /// Collection index. + pub(crate) index: usize, /// Programmatic option value. pub(crate) value: RcPartialEqValue, /// Display/search text. pub(crate) text_value: String, - /// DOM id. - pub(crate) id: String, - /// Whether this option is disabled. - pub(crate) disabled: bool, } /// Resolve an option's searchable text value. @@ -108,66 +106,81 @@ fn sync_option_state(options: &mut Vec, option_state: OptionState) .iter() .position(|option| option.id == option_state.id) { - if options[position].tab_index == option_state.tab_index { + if options[position].index == option_state.index { options[position] = option_state; - } else { - options.remove(position); - insert_option(options, option_state); + return; } - } else { - insert_option(options, option_state); + options.remove(position); } + insert_option(options, option_state); } fn insert_option(options: &mut Vec, option_state: OptionState) { - let insert_at = options.partition_point(|option| option.tab_index <= option_state.tab_index); + let insert_at = options.partition_point(|option| option.index <= option_state.index); options.insert(insert_at, option_state); } /// Remove a registered option by id. pub(crate) fn remove_option(mut options: Signal>, id: &str) { - options.write().retain(|option| option.id != id); + remove_option_state(&mut options.write(), id); +} + +fn remove_option_state(options: &mut Vec, id: &str) { + options.retain(|option| option.id != id); } #[cfg(test)] mod tests { use super::*; - fn option(id: &str, tab_index: usize) -> OptionState { + fn option(id: &str, index: usize) -> OptionState { OptionState { - tab_index, + id: id.to_string(), + index, value: RcPartialEqValue::new(id.to_string()), text_value: id.to_string(), - id: id.to_string(), - disabled: false, } } fn ids(options: &[OptionState]) -> Vec<&str> { - options.iter().map(|option| option.id.as_str()).collect() + options + .iter() + .map(|option| option.text_value.as_str()) + .collect() } - fn tab_indices(options: &[OptionState]) -> Vec { - options.iter().map(|option| option.tab_index).collect() + fn indices(options: &[OptionState]) -> Vec { + options.iter().map(|option| option.index).collect() } #[test] - fn sync_option_state_repositions_when_tab_index_changes() { + fn sync_option_state_keeps_sorted_order() { let mut options = vec![option("a", 0), option("b", 1), option("c", 2)]; - sync_option_state(&mut options, option("a", 3)); + sync_option_state(&mut options, option("d", 3)); - assert_eq!(ids(&options), ["b", "c", "a"]); - assert_eq!(tab_indices(&options), [1, 2, 3]); + assert_eq!(ids(&options), ["a", "b", "c", "d"]); + assert_eq!(indices(&options), [0, 1, 2, 3]); } #[test] - fn sync_option_state_keeps_sorted_order_when_tab_index_decreases() { + fn sync_option_state_updates_matching_id_and_reorders() { let mut options = vec![option("a", 0), option("b", 1), option("c", 2)]; - sync_option_state(&mut options, option("c", 0)); + sync_option_state(&mut options, option("b", 3)); assert_eq!(ids(&options), ["a", "c", "b"]); - assert_eq!(tab_indices(&options), [0, 0, 1]); + assert_eq!(indices(&options), [0, 2, 3]); + } + + #[test] + fn removing_stale_option_does_not_remove_option_that_moved_to_same_index() { + let mut options = vec![option("a", 0), option("b", 1)]; + + sync_option_state(&mut options, option("b", 0)); + remove_option_state(&mut options, "a"); + + assert_eq!(ids(&options), ["b"]); + assert_eq!(indices(&options), [0]); } } diff --git a/primitives/src/tabs.rs b/primitives/src/tabs.rs index e80285947..89ebcbc33 100644 --- a/primitives/src/tabs.rs +++ b/primitives/src/tabs.rs @@ -1,7 +1,7 @@ //! Defines the [`Tabs`] component and its sub-components. use crate::{ - focus::{use_focus_controlled_item_disabled, use_focus_provider, FocusState}, + collection::{collection_item, use_collection_provider, use_item, CollectionState}, use_controlled, use_id_or, use_unique_id, }; use dioxus::prelude::*; @@ -14,11 +14,10 @@ struct TabsContext { disabled: ReadSignal, // Focus state - focus: FocusState, + focus: CollectionState, // Orientation horizontal: ReadSignal, - roving_loop: ReadSignal, // ARIA attributes tab_content_ids: Signal>, @@ -111,7 +110,7 @@ pub fn Tabs(props: TabsProps) -> Element { let (value, set_value) = use_controlled(props.value, props.default_value, props.on_value_change); - let focus = use_focus_provider(props.roving_loop); + let focus = use_collection_provider(props.roving_loop); let mut ctx = use_context_provider(|| TabsContext { value: value.into(), set_value, @@ -120,7 +119,6 @@ pub fn Tabs(props: TabsProps) -> Element { focus, horizontal: props.horizontal, - roving_loop: props.roving_loop, tab_content_ids: Signal::new(Vec::new()), }); @@ -129,7 +127,7 @@ pub fn Tabs(props: TabsProps) -> Element { "data-orientation": if (props.horizontal)() { "horizontal" } else { "vertical" }, "data-disabled": (props.disabled)(), - onfocusout: move |_| ctx.focus.blur(), + onfocusout: move |_| ctx.focus.clear_focus(), ..props.attributes, {props.children} @@ -285,22 +283,14 @@ pub fn TabTrigger(props: TabTriggerProps) -> Element { let value = props.value.clone(); let selected = use_memo(move || (ctx.value)() == value); - let tab_index = use_memo(move || { - if !(ctx.roving_loop)() { - return "0"; - } - - if selected() { - return "0"; - } - if ctx.focus.is_focused(props.index.cloned()) { - return "0"; - } - "-1" - }); - let disabled = move || (ctx.disabled)() || (props.disabled)(); - let onmounted = use_focus_controlled_item_disabled(props.index, disabled); + let item = use_item( + collection_item(ctx.focus, props.index) + .disabled(disabled) + .selected(move || selected.cloned()), + ); + let onmounted = item.onmounted(); + let tab_index = item.tabindex; rsx! { button { diff --git a/primitives/src/tag_group.rs b/primitives/src/tag_group.rs new file mode 100644 index 000000000..009ddaf04 --- /dev/null +++ b/primitives/src/tag_group.rs @@ -0,0 +1,1048 @@ +//! Defines the [`TagGroup`] and [`TagGroupMulti`] components and their sub-components. + +use dioxus::prelude::*; + +use crate::{ + collection::{collection_item, use_collection_provider, use_item, CollectionState}, + 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, + set_values: Callback>, + clear_selection: Callback<()>, + selection_mode: SelectionMode, + items: Signal>, + focus: CollectionState, + 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, + set_values: 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, item: TagItem) { + sync_tag_item(&mut self.items.write(), 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.focused_index().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.loops(), + ) + }) + }); + let remaining_selected_values = + selected_values_after_removal(&selected_values, &removed_selected_values); + + 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 => { + self.set_values.call(remaining_selected_values); + } + } + } + + true + } +} + +fn sync_tag_item(items: &mut Vec, mut item: TagItem) { + if let Some(position) = items.iter().position(|existing| existing.id == item.id) { + item.removed = items[position].removed && items[position].value == item.value; + items.remove(position); + } + insert_tag_item(items, item); +} + +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 selected_values_after_removal( + selected_values: &[RcPartialEqValue], + removed_selected_values: &[RcPartialEqValue], +) -> Vec { + selected_values + .iter() + .filter(|selected| { + !removed_selected_values + .iter() + .any(|removed| removed == *selected) + }) + .cloned() + .collect() +} + +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 set_values = use_callback(move |incoming: Vec| { + if let Some(incoming) = incoming.into_iter().next() { + 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)); + } else { + internal_value.set(None); + on_change.call(None); + } + }); + 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, + set_values, + 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 set_values = use_callback(move |values: Vec| { + let values = values + .into_iter() + .map(|value| { + value + .as_ref::() + .unwrap_or_else(|| panic!("TagGroupMulti and TagOption value types must match")) + .clone() + }) + .collect(); + set_multi_internal.call(values); + }); + let clear_selection = use_callback(move |_| { + set_multi_internal.call(Vec::new()); + }); + + use_tag_group_inner( + TagGroupSharedProps::from_multi(&props), + TagGroupSelection { + values, + set_value, + set_values, + 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, + set_values, + clear_selection, + selection_mode, + } = selection; + + let items: Signal> = use_signal(Vec::default); + let focus = use_collection_provider(roving_loop); + + let state = TagGroupState { + values, + set_value, + set_values, + 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_available_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 item = use_item( + collection_item(state.focus, index) + .key(move || Some(item_id())) + .disabled(move || disabled.cloned()) + .hidden(move || is_removed.cloned()), + ); + let tabindex = item.tabindex; + let onmounted = item.onmounted(); + + 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} + } + } +} diff --git a/primitives/src/toggle_group.rs b/primitives/src/toggle_group.rs index f2b3e7716..6413c9488 100644 --- a/primitives/src/toggle_group.rs +++ b/primitives/src/toggle_group.rs @@ -1,7 +1,7 @@ //! Defines the [`ToggleGroup`] component and its sub-components, which manage a group of toggle buttons with single or multiple selection. use crate::{ - focus::{use_focus_controlled_item_disabled, use_focus_provider, FocusState}, + collection::{collection_item, use_collection_provider, use_item, CollectionState}, toggle::Toggle, use_controlled, }; @@ -20,7 +20,7 @@ struct ToggleGroupCtx { allow_multiple_pressed: ReadSignal, // Focus state - focus: FocusState, + focus: CollectionState, horizontal: ReadSignal, roving_loop: ReadSignal, @@ -72,10 +72,6 @@ impl ToggleGroupCtx { self.focus.focus_prev(); } - - fn is_roving_loop(&self) -> bool { - (self.roving_loop)() - } } /// The props for the [`ToggleGroup`] component @@ -150,7 +146,7 @@ pub fn ToggleGroup(props: ToggleGroupProps) -> Element { props.on_pressed_change, ); - let focus = use_focus_provider(props.roving_loop); + let focus = use_collection_provider(props.roving_loop); let mut ctx = use_context_provider(|| ToggleGroupCtx { pressed, set_pressed, @@ -164,7 +160,7 @@ pub fn ToggleGroup(props: ToggleGroupProps) -> Element { rsx! { div { - onfocusout: move |_| ctx.focus.set_focus(None), + onfocusout: move |_| ctx.focus.clear_focus(), "data-orientation": ctx.orientation(), "data-allow-multiple-pressed": ctx.allow_multiple_pressed, @@ -233,20 +229,10 @@ pub fn ToggleItem(props: ToggleItemProps) -> Element { pressed.set(is_pressed); }); - // Tab index for roving index - let tab_index = use_memo(move || { - if !ctx.is_roving_loop() { - return "0"; - } - - match ctx.focus.recent_focus_or_default() == props.index.cloned() { - true => "0", - false => "-1", - } - }); - let disabled = move || (ctx.disabled)() || (props.disabled)(); - let onmounted = use_focus_controlled_item_disabled(props.index, disabled); + let item = use_item(collection_item(ctx.focus, props.index).disabled(disabled)); + let tab_index = item.tabindex; + let onmounted = item.onmounted(); rsx! { Toggle { diff --git a/primitives/src/toolbar.rs b/primitives/src/toolbar.rs index 54cd605cb..151183b0c 100644 --- a/primitives/src/toolbar.rs +++ b/primitives/src/toolbar.rs @@ -1,7 +1,7 @@ //! Defines the [`Toolbar`] component and its sub-components, which provide a container to group related buttons and controls with keyboard navigation. +use crate::collection::{collection_item, use_collection_provider, use_item, CollectionState}; use dioxus::prelude::*; -use std::rc::Rc; #[derive(Clone, Copy)] struct ToolbarCtx { @@ -9,7 +9,7 @@ struct ToolbarCtx { disabled: ReadSignal, // Focus management - focused_index: Signal>, + focus: CollectionState, // Orientation horizontal: ReadSignal, @@ -17,11 +17,7 @@ struct ToolbarCtx { impl ToolbarCtx { fn set_focus(&mut self, index: Option) { - self.focused_index.set(index); - } - - fn is_focused(&self, index: usize) -> bool { - (self.focused_index)() == Some(index) + self.focus.set_focus(index); } fn orientation(&self) -> &'static str { @@ -92,9 +88,10 @@ pub struct ToolbarProps { /// - `data-disabled`: Indicates if the toolbar is disabled. Values are `true` or `false`. #[component] pub fn Toolbar(props: ToolbarProps) -> Element { + let focus = use_collection_provider(ReadSignal::new(Signal::new(false))); let mut ctx = use_context_provider(|| ToolbarCtx { disabled: props.disabled, - focused_index: Signal::new(None), + focus, horizontal: props.horizontal, }); @@ -105,7 +102,7 @@ pub fn Toolbar(props: ToolbarProps) -> Element { "data-disabled": (props.disabled)(), aria_label: props.aria_label, - onfocusout: move |_| ctx.set_focus(None), + onfocusout: move |_| ctx.focus.clear_focus(), ..props.attributes, {props.children} @@ -174,31 +171,18 @@ pub struct ToolbarButtonProps { pub fn ToolbarButton(props: ToolbarButtonProps) -> Element { let mut ctx: ToolbarCtx = use_context(); - // Handle button ref for focus management - let mut button_ref: Signal>> = use_signal(|| None); - - // Check if this button is focused - let is_focused = use_memo(move || ctx.is_focused((props.index)())); - - // Set focus when needed - use_effect(move || { - if is_focused() { - if let Some(md) = button_ref() { - spawn(async move { - let _ = md.set_focus(true).await; - }); - } - } - }); + let disabled = move || (ctx.disabled)() || (props.disabled)(); + let onmounted = + use_item(collection_item(ctx.focus, props.index).disabled(disabled)).onmounted(); rsx! { button { type: "button", tabindex: "0", - disabled: (ctx.disabled)() || (props.disabled)(), - "data-disabled": (ctx.disabled)() || (props.disabled)(), + disabled: disabled(), + "data-disabled": disabled(), - onmounted: move |data: Event| button_ref.set(Some(data.data())), + onmounted, onfocus: move |_| ctx.set_focus(Some((props.index)())), onclick: move |_| {