Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/playwright-tests/fullstack-routing.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
50 changes: 50 additions & 0 deletions packages/playwright-tests/fullstack-routing/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ enum Route {

#[route("/can-go-back")]
HydrateCanGoBack,

#[nest("/class")]
#[layout(NavTestClass)]
#[route("/")]
HomeTestClass,

#[route("/other")]
OtherTestClass,
}

#[component]
Expand Down Expand Up @@ -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::<Route> {}
}
}
}
}

#[component]
pub fn HomeTestClass() -> Element {
rsx! {
span { "home test class" }
}
}

#[component]
pub fn OtherTestClass() -> Element {
rsx! {
span { "other test class" }
}
}
38 changes: 22 additions & 16 deletions packages/router/src/components/link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

/// A class to apply to the generated HTML anchor tag if the `target` route is not active.
pub inactive_class: Option<String>,

/// The children to render within the generated HTML anchor tag.
pub children: Element,

Expand Down Expand Up @@ -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 `<a>` 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.
Expand Down Expand Up @@ -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,
Expand All @@ -137,6 +149,7 @@ impl Debug for LinkProps {
pub fn Link(props: LinkProps) -> Element {
let LinkProps {
active_class,
inactive_class,
children,
attributes,
new_tab,
Expand Down Expand Up @@ -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");
Expand Down
134 changes: 134 additions & 0 deletions packages/router/tests/via_ssr/link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,140 @@ fn with_active_class_inactive() {
assert_eq!(prepare::<Route>(), 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!(
"<h1>App</h1><a {href} {class} {aria}>Link</a>",
href = r#"href="/""#,
class = r#"class="test_class""#,
aria = r#"aria-current="page""#,
);

assert_eq!(prepare::<Route>(), 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!(
"<h1>App</h1><a {href} {class}>Link</a>",
href = r#"href="/test""#,
class = r#"class="test_class inactive_class""#,
);

assert_eq!(prepare::<Route>(), 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!(
"<h1>App</h1><a {href} {class} {aria}>Link</a>",
href = r#"href="/""#,
class = r#"class="test_class active_class""#,
aria = r#"aria-current="page""#,
);

assert_eq!(prepare::<Route>(), 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!(
"<h1>App</h1><a {href} {class}>Link</a>",
href = r#"href="/test""#,
class = r#"class="test_class inactive_class""#,
);

assert_eq!(prepare::<Route>(), expected);
}

#[test]
fn with_id() {
#[derive(Routable, Clone)]
Expand Down
Loading