From 22d1c9663988910c6f0c7fc3ee9bff1a6033bfb1 Mon Sep 17 00:00:00 2001 From: nerjs Date: Wed, 25 Mar 2026 00:22:33 +0100 Subject: [PATCH] feat(router): support inactive_class in Link with e2e coverage - add inactive_class prop - append it when route is not active - add playwright test for SSR and SPA class switching --- .../fullstack-routing.spec.js | 31 ++++ .../fullstack-routing/src/main.rs | 50 +++++++ packages/router/src/components/link.rs | 38 ++--- packages/router/tests/via_ssr/link.rs | 134 ++++++++++++++++++ 4 files changed, 237 insertions(+), 16 deletions(-) diff --git a/packages/playwright-tests/fullstack-routing.spec.js b/packages/playwright-tests/fullstack-routing.spec.js index 2e907a0ca6..474869cff9 100644 --- a/packages/playwright-tests/fullstack-routing.spec.js +++ b/packages/playwright-tests/fullstack-routing.spec.js @@ -94,3 +94,34 @@ test("click home link from blog", async ({ page }) => { const text = await page.textContent("body"); expect(text).toContain("Home"); }); + +test("Link applies active and inactive classes correctly", async ({ page }) => { + await page.goto("http://localhost:8888/class"); + + const linkHome = page.locator("#link-home"); + const linkOther = page.locator("#link-other"); + + // Initial SSR state + // Home link should be active + await expect(linkHome).toHaveClass(/base-class class-active/); + await expect(linkHome).not.toHaveClass(/class-inactive/); + + // Other link should be inactive + await expect(linkOther).toHaveClass(/base-class class-inactive/); + await expect(linkOther).not.toHaveClass(/class-active/); + + // Perform SPA navigation + await linkOther.click(); + + // Wait for route change + await expect(page).toHaveURL(/\/class\/other/); + + // After navigation + // Home link becomes inactive + await expect(linkHome).toHaveClass(/base-class class-inactive/); + await expect(linkHome).not.toHaveClass(/class-active/); + + // Other link becomes active + await expect(linkOther).toHaveClass(/base-class class-active/); + await expect(linkOther).not.toHaveClass(/class-inactive/); +}); \ No newline at end of file diff --git a/packages/playwright-tests/fullstack-routing/src/main.rs b/packages/playwright-tests/fullstack-routing/src/main.rs index 1ea1b2b631..ccd5001418 100644 --- a/packages/playwright-tests/fullstack-routing/src/main.rs +++ b/packages/playwright-tests/fullstack-routing/src/main.rs @@ -35,6 +35,14 @@ enum Route { #[route("/can-go-back")] HydrateCanGoBack, + + #[nest("/class")] + #[layout(NavTestClass)] + #[route("/")] + HomeTestClass, + + #[route("/other")] + OtherTestClass, } #[component] @@ -113,3 +121,45 @@ pub fn HydrateCanGoBack() -> Element { }, } } + +pub fn NavTestClass() -> Element { + rsx! { + div { + div { + Link { + id: "link-home", + to: Route::HomeTestClass, + class: "base-class", + active_class: "class-active", + inactive_class: "class-inactive", + "Home test page" + } + Link { + id: "link-other", + to: Route::OtherTestClass, + class: "base-class", + active_class: "class-active", + inactive_class: "class-inactive", + "Other test page" + } + } + div { + Outlet:: {} + } + } + } +} + +#[component] +pub fn HomeTestClass() -> Element { + rsx! { + span { "home test class" } + } +} + +#[component] +pub fn OtherTestClass() -> Element { + rsx! { + span { "other test class" } + } +} diff --git a/packages/router/src/components/link.rs b/packages/router/src/components/link.rs index 26a4c60516..8195eb7d37 100644 --- a/packages/router/src/components/link.rs +++ b/packages/router/src/components/link.rs @@ -22,6 +22,9 @@ pub struct LinkProps { /// A class to apply to the generate HTML anchor tag if the `target` route is active. pub active_class: Option, + /// A class to apply to the generated HTML anchor tag if the `target` route is not active. + pub inactive_class: Option, + /// The children to render within the generated HTML anchor tag. pub children: Element, @@ -84,6 +87,14 @@ impl Debug for LinkProps { /// However, in the background a [`Link`] still generates an anchor, which you can use for styling /// as normal. /// +/// # Classes +/// - `class`, if provided, is applied to the generated `` element. +/// - `active_class`, if provided, is appended (space-separated) when the current route matches the `target`. +/// - `inactive_class`, if provided, is appended (space-separated) when the current route does not match the `target`. +/// +/// When multiple class values are applied, they are concatenated with a space in the order: +/// `class` -> `active_class` / `inactive_class`. +/// /// # External targets /// When the [`Link`]s target is an [`NavigationTarget::External`] target, that is used as the `href` directly. This /// means that a [`Link`] can always navigate to an [`NavigationTarget::External`] target, even if the [`dioxus_history::History`] does not support it. @@ -114,6 +125,7 @@ impl Debug for LinkProps { /// rsx! { /// Link { /// active_class: "active", +/// inactive_class: "inactive", /// class: "link_class", /// id: "link_id", /// new_tab: true, @@ -137,6 +149,7 @@ impl Debug for LinkProps { pub fn Link(props: LinkProps) -> Element { let LinkProps { active_class, + inactive_class, children, attributes, new_tab, @@ -172,23 +185,16 @@ pub fn Link(props: LinkProps) -> Element { NavigationTarget::External(route) => route.clone(), }; - let mut class_ = String::new(); - if let Some(c) = class { - class_.push_str(&c); - } - if let Some(c) = active_class { - if href == current_url { - if !class_.is_empty() { - class_.push(' '); - } - class_.push_str(&c); - } - } - - let class = if class_.is_empty() { - None + let extra_class = if href.trim_end_matches("/") == current_url.trim_end_matches("/") { + active_class } else { - Some(class_) + inactive_class + }; + let class = match (class, extra_class) { + (None, None) => None, + (None, Some(s)) => Some(s), + (Some(s), None) => Some(s), + (Some(active), Some(inactive)) => Some(format!("{active} {inactive}")), }; let aria_current = (href == current_url).then_some("page"); diff --git a/packages/router/tests/via_ssr/link.rs b/packages/router/tests/via_ssr/link.rs index c9f3fbaf30..ac584d323a 100644 --- a/packages/router/tests/via_ssr/link.rs +++ b/packages/router/tests/via_ssr/link.rs @@ -237,6 +237,140 @@ fn with_active_class_inactive() { assert_eq!(prepare::(), expected); } +#[test] +fn with_inactive_class_active() { + #[derive(Routable, Clone)] + enum Route { + #[route("/")] + Root {}, + } + + #[component] + fn Root() -> Element { + rsx! { + Link { + to: Route::Root {}, + inactive_class: "inactive_class", + class: "test_class", + "Link" + } + } + } + + let expected = format!( + "

App

Link", + href = r#"href="/""#, + class = r#"class="test_class""#, + aria = r#"aria-current="page""#, + ); + + assert_eq!(prepare::(), expected); +} + +#[test] +fn with_inactive_class_inactive() { + #[derive(Routable, Clone)] + enum Route { + #[route("/")] + Root {}, + #[route("/test")] + Test {}, + } + + #[component] + fn Test() -> Element { + unimplemented!() + } + + #[component] + fn Root() -> Element { + rsx! { + Link { + to: Route::Test {}, + inactive_class: "inactive_class", + class: "test_class", + "Link" + } + } + } + + let expected = format!( + "

App

Link", + href = r#"href="/test""#, + class = r#"class="test_class inactive_class""#, + ); + + assert_eq!(prepare::(), expected); +} + +#[test] +fn with_active_and_inactive_class_active() { + #[derive(Routable, Clone)] + enum Route { + #[route("/")] + Root {}, + } + + #[component] + fn Root() -> Element { + rsx! { + Link { + to: Route::Root {}, + active_class: "active_class", + inactive_class: "inactive_class", + class: "test_class", + "Link" + } + } + } + + let expected = format!( + "

App

Link", + href = r#"href="/""#, + class = r#"class="test_class active_class""#, + aria = r#"aria-current="page""#, + ); + + assert_eq!(prepare::(), expected); +} + +#[test] +fn with_active_and_inactive_class_inactive() { + #[derive(Routable, Clone)] + enum Route { + #[route("/")] + Root {}, + #[route("/test")] + Test {}, + } + + #[component] + fn Test() -> Element { + unimplemented!() + } + + #[component] + fn Root() -> Element { + rsx! { + Link { + to: Route::Test {}, + active_class: "active_class", + inactive_class: "inactive_class", + class: "test_class", + "Link" + } + } + } + + let expected = format!( + "

App

Link", + href = r#"href="/test""#, + class = r#"class="test_class inactive_class""#, + ); + + assert_eq!(prepare::(), expected); +} + #[test] fn with_id() { #[derive(Routable, Clone)]