From 976cee57110170e85f6727071360dc34582fe858 Mon Sep 17 00:00:00 2001
From: RAprogramm
Date: Wed, 17 Dec 2025 07:35:10 +0700
Subject: [PATCH 1/3] #13 feat: add Playwright e2e test infrastructure
---
e2e/.gitignore | 4 +
e2e/package-lock.json | 111 ++++++++++++++++++++++++++
e2e/package.json | 21 +++++
e2e/playwright.config.ts | 55 +++++++++++++
e2e/tests/basic.spec.ts | 90 +++++++++++++++++++++
e2e/tests/bootstrap.spec.ts | 109 +++++++++++++++++++++++++
e2e/tests/fixtures.ts | 90 +++++++++++++++++++++
e2e/tests/nested-routes.spec.ts | 137 ++++++++++++++++++++++++++++++++
e2e/tests/tailwind.spec.ts | 119 +++++++++++++++++++++++++++
e2e/tsconfig.json | 16 ++++
10 files changed, 752 insertions(+)
create mode 100644 e2e/.gitignore
create mode 100644 e2e/package-lock.json
create mode 100644 e2e/package.json
create mode 100644 e2e/playwright.config.ts
create mode 100644 e2e/tests/basic.spec.ts
create mode 100644 e2e/tests/bootstrap.spec.ts
create mode 100644 e2e/tests/fixtures.ts
create mode 100644 e2e/tests/nested-routes.spec.ts
create mode 100644 e2e/tests/tailwind.spec.ts
create mode 100644 e2e/tsconfig.json
diff --git a/e2e/.gitignore b/e2e/.gitignore
new file mode 100644
index 0000000..968c768
--- /dev/null
+++ b/e2e/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+playwright-report/
+test-results/
+.playwright/
diff --git a/e2e/package-lock.json b/e2e/package-lock.json
new file mode 100644
index 0000000..99c9271
--- /dev/null
+++ b/e2e/package-lock.json
@@ -0,0 +1,111 @@
+{
+ "name": "yew-nav-link-e2e",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "yew-nav-link-e2e",
+ "version": "1.0.0",
+ "devDependencies": {
+ "@playwright/test": "^1.49.0",
+ "@types/node": "^22.10.2",
+ "typescript": "^5.7.2"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.57.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
+ "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.57.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "22.19.3",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
+ "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.57.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
+ "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.57.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.57.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
+ "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/e2e/package.json b/e2e/package.json
new file mode 100644
index 0000000..92dacdc
--- /dev/null
+++ b/e2e/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "yew-nav-link-e2e",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "test": "playwright test",
+ "test:ui": "playwright test --ui",
+ "test:headed": "playwright test --headed",
+ "test:debug": "playwright test --debug",
+ "test:basic": "playwright test tests/basic.spec.ts",
+ "test:nested": "playwright test tests/nested-routes.spec.ts",
+ "test:bootstrap": "playwright test tests/bootstrap.spec.ts",
+ "test:tailwind": "playwright test tests/tailwind.spec.ts",
+ "report": "playwright show-report"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.49.0",
+ "@types/node": "^22.10.2",
+ "typescript": "^5.7.2"
+ }
+}
diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts
new file mode 100644
index 0000000..a190b24
--- /dev/null
+++ b/e2e/playwright.config.ts
@@ -0,0 +1,55 @@
+import { defineConfig, devices } from "@playwright/test";
+
+const EXAMPLES = {
+ basic: 8080,
+ "nested-routes": 8081,
+ bootstrap: 8082,
+ tailwind: 8083,
+} as const;
+
+export default defineConfig({
+ testDir: "./tests",
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ workers: process.env.CI ? 1 : undefined,
+ reporter: [["html", { open: "never" }], ["list"]],
+
+ use: {
+ trace: "on-first-retry",
+ screenshot: "only-on-failure",
+ video: "on-first-retry",
+ },
+
+ projects: [
+ {
+ name: "chromium",
+ use: { ...devices["Desktop Chrome"] },
+ },
+ {
+ name: "firefox",
+ use: { ...devices["Desktop Firefox"] },
+ },
+ {
+ name: "webkit",
+ use: { ...devices["Desktop Safari"] },
+ },
+ {
+ name: "mobile-chrome",
+ use: { ...devices["Pixel 5"] },
+ },
+ {
+ name: "mobile-safari",
+ use: { ...devices["iPhone 12"] },
+ },
+ ],
+
+ webServer: Object.entries(EXAMPLES).map(([name, port]) => ({
+ command: `cd ../examples/${name} && trunk serve --port ${port}`,
+ url: `http://localhost:${port}`,
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ })),
+});
+
+export { EXAMPLES };
diff --git a/e2e/tests/basic.spec.ts b/e2e/tests/basic.spec.ts
new file mode 100644
index 0000000..1518aab
--- /dev/null
+++ b/e2e/tests/basic.spec.ts
@@ -0,0 +1,90 @@
+import { test, expect } from "./fixtures";
+
+test.describe("Basic Example", () => {
+ test.beforeEach(async ({ basic }) => {
+ await basic.goto();
+ });
+
+ test("renders navigation with all links", async ({ basic }) => {
+ await expect(basic.navLink("Home")).toBeVisible();
+ await expect(basic.navLink("About")).toBeVisible();
+ await expect(basic.navLink("Contact")).toBeVisible();
+ });
+
+ test("Home link is active on initial load", async ({ basic }) => {
+ await expect(basic.navLink("Home")).toHaveClass(/active/);
+ await expect(basic.navLink("About")).not.toHaveClass(/active/);
+ await expect(basic.navLink("Contact")).not.toHaveClass(/active/);
+ });
+
+ test("displays Home page content", async ({ basic }) => {
+ await expect(basic.heading()).toHaveText("Home");
+ });
+
+ test("navigates to About and updates active state", async ({ basic }) => {
+ await basic.clickNav("About");
+
+ await expect(basic.navLink("About")).toHaveClass(/active/);
+ await expect(basic.navLink("Home")).not.toHaveClass(/active/);
+ await expect(basic.heading()).toHaveText("About");
+ });
+
+ test("navigates to Contact and updates active state", async ({ basic }) => {
+ await basic.clickNav("Contact");
+
+ await expect(basic.navLink("Contact")).toHaveClass(/active/);
+ await expect(basic.navLink("Home")).not.toHaveClass(/active/);
+ await expect(basic.heading()).toHaveText("Contact");
+ });
+
+ test("navigation sequence maintains correct active states", async ({
+ basic,
+ }) => {
+ await basic.clickNav("About");
+ await expect(basic.navLink("About")).toHaveClass(/active/);
+
+ await basic.clickNav("Contact");
+ await expect(basic.navLink("Contact")).toHaveClass(/active/);
+ await expect(basic.navLink("About")).not.toHaveClass(/active/);
+
+ await basic.clickNav("Home");
+ await expect(basic.navLink("Home")).toHaveClass(/active/);
+ await expect(basic.navLink("Contact")).not.toHaveClass(/active/);
+ });
+
+ test("direct URL navigation sets correct active state", async ({ basic }) => {
+ await basic.goto("/about");
+
+ await expect(basic.navLink("About")).toHaveClass(/active/);
+ await expect(basic.navLink("Home")).not.toHaveClass(/active/);
+ await expect(basic.heading()).toHaveText("About");
+ });
+
+ test("handles 404 page", async ({ basic }) => {
+ await basic.goto("/nonexistent");
+
+ await expect(basic.heading()).toHaveText("404 - Not Found");
+ });
+
+ test("all NavLinks have nav-link class", async ({ basic }) => {
+ const links = await basic.page.locator("nav a").all();
+
+ for (const link of links) {
+ await expect(link).toHaveClass(/nav-link/);
+ }
+ });
+
+ test("browser back/forward navigation works", async ({ basic }) => {
+ await basic.clickNav("About");
+ await expect(basic.navLink("About")).toHaveClass(/active/);
+
+ await basic.clickNav("Contact");
+ await expect(basic.navLink("Contact")).toHaveClass(/active/);
+
+ await basic.page.goBack();
+ await expect(basic.navLink("About")).toHaveClass(/active/);
+
+ await basic.page.goForward();
+ await expect(basic.navLink("Contact")).toHaveClass(/active/);
+ });
+});
diff --git a/e2e/tests/bootstrap.spec.ts b/e2e/tests/bootstrap.spec.ts
new file mode 100644
index 0000000..ebc8b9f
--- /dev/null
+++ b/e2e/tests/bootstrap.spec.ts
@@ -0,0 +1,109 @@
+import { test, expect } from "./fixtures";
+
+test.describe("Bootstrap Example", () => {
+ test.beforeEach(async ({ bootstrap }) => {
+ await bootstrap.goto();
+ });
+
+ test("renders Bootstrap navbar", async ({ bootstrap }) => {
+ await expect(bootstrap.page.locator(".navbar")).toBeVisible();
+ await expect(bootstrap.page.locator(".navbar-brand")).toHaveText("MyApp");
+ });
+
+ test("renders all navigation links", async ({ bootstrap }) => {
+ await expect(bootstrap.navLink("Home")).toBeVisible();
+ await expect(bootstrap.navLink("Products")).toBeVisible();
+ await expect(bootstrap.navLink("Pricing")).toBeVisible();
+ await expect(bootstrap.navLink("Contact")).toBeVisible();
+ });
+
+ test("Home is active on initial load", async ({ bootstrap }) => {
+ await expect(bootstrap.navLink("Home")).toHaveClass(/active/);
+ await expect(bootstrap.page.locator(".card-title")).toHaveText("Welcome");
+ });
+
+ test("NavLinks have Bootstrap-compatible classes", async ({ bootstrap }) => {
+ const links = await bootstrap.page.locator(".navbar-nav a").all();
+
+ for (const link of links) {
+ await expect(link).toHaveClass(/nav-link/);
+ }
+ });
+
+ test("navigates to Products", async ({ bootstrap }) => {
+ await bootstrap.clickNav("Products");
+
+ await expect(bootstrap.navLink("Products")).toHaveClass(/active/);
+ await expect(bootstrap.navLink("Home")).not.toHaveClass(/active/);
+ await expect(bootstrap.page.locator(".card-title")).toHaveText("Products");
+ });
+
+ test("navigates to Pricing", async ({ bootstrap }) => {
+ await bootstrap.clickNav("Pricing");
+
+ await expect(bootstrap.navLink("Pricing")).toHaveClass(/active/);
+ await expect(bootstrap.page.locator(".card-title")).toHaveText("Pricing");
+ });
+
+ test("navigates to Contact", async ({ bootstrap }) => {
+ await bootstrap.clickNav("Contact");
+
+ await expect(bootstrap.navLink("Contact")).toHaveClass(/active/);
+ await expect(bootstrap.page.locator(".card-title")).toHaveText("Contact");
+ });
+
+ test("full navigation cycle", async ({ bootstrap }) => {
+ const routes = ["Products", "Pricing", "Contact", "Home"];
+
+ for (const route of routes) {
+ await bootstrap.clickNav(route);
+ await expect(bootstrap.navLink(route)).toHaveClass(/active/);
+ }
+ });
+
+ test("direct URL navigation", async ({ bootstrap }) => {
+ await bootstrap.goto("/pricing");
+
+ await expect(bootstrap.navLink("Pricing")).toHaveClass(/active/);
+ await expect(bootstrap.page.locator(".card-title")).toHaveText("Pricing");
+ });
+
+ test("handles 404 with Bootstrap alert", async ({ bootstrap }) => {
+ await bootstrap.goto("/nonexistent");
+
+ await expect(bootstrap.page.locator(".alert-warning")).toBeVisible();
+ await expect(bootstrap.page.locator(".alert-heading")).toHaveText(
+ "404 - Not Found"
+ );
+ });
+
+ test("browser history navigation", async ({ bootstrap }) => {
+ await bootstrap.clickNav("Products");
+ await bootstrap.clickNav("Pricing");
+
+ await bootstrap.page.goBack();
+ await expect(bootstrap.navLink("Products")).toHaveClass(/active/);
+
+ await bootstrap.page.goBack();
+ await expect(bootstrap.navLink("Home")).toHaveClass(/active/);
+
+ await bootstrap.page.goForward();
+ await expect(bootstrap.navLink("Products")).toHaveClass(/active/);
+ });
+
+ test("only one link is active at a time", async ({ bootstrap }) => {
+ const routes = ["Home", "Products", "Pricing", "Contact"];
+
+ for (const active of routes) {
+ await bootstrap.clickNav(active);
+
+ for (const route of routes) {
+ if (route === active) {
+ await expect(bootstrap.navLink(route)).toHaveClass(/active/);
+ } else {
+ await expect(bootstrap.navLink(route)).not.toHaveClass(/active/);
+ }
+ }
+ }
+ });
+});
diff --git a/e2e/tests/fixtures.ts b/e2e/tests/fixtures.ts
new file mode 100644
index 0000000..1f928cd
--- /dev/null
+++ b/e2e/tests/fixtures.ts
@@ -0,0 +1,90 @@
+import { test as base, type Page, type Locator } from "@playwright/test";
+
+const PORTS = {
+ basic: 8080,
+ "nested-routes": 8081,
+ bootstrap: 8082,
+ tailwind: 8083,
+} as const;
+
+type ExampleName = keyof typeof PORTS;
+
+export class NavLinkPage {
+ readonly page: Page;
+ readonly baseUrl: string;
+
+ constructor(page: Page, example: ExampleName) {
+ this.page = page;
+ this.baseUrl = `http://localhost:${PORTS[example]}`;
+ }
+
+ async goto(path = "/"): Promise {
+ await this.page.goto(`${this.baseUrl}${path}`);
+ await this.waitForWasm();
+ }
+
+ async waitForWasm(): Promise {
+ await this.page.waitForFunction(() => {
+ return document.querySelector("nav") !== null;
+ });
+ }
+
+ navLink(text: string): Locator {
+ return this.page.locator(`nav a:has-text("${text}")`);
+ }
+
+ mainNavLink(text: string): Locator {
+ return this.page.locator(`.main-nav a:has-text("${text}")`);
+ }
+
+ subNavLink(text: string): Locator {
+ return this.page.locator(`.sub-nav a:has-text("${text}")`);
+ }
+
+ heading(level: 1 | 2 | 3 = 1): Locator {
+ return this.page.locator(`h${level}`).first();
+ }
+
+ async clickNav(text: string): Promise {
+ await this.navLink(text).click();
+ await this.page.waitForLoadState("networkidle");
+ }
+
+ async expectActive(text: string): Promise {
+ await this.navLink(text).waitFor({ state: "visible" });
+ const link = this.navLink(text);
+ await link.evaluate((el) => el.classList.contains("active"));
+ }
+
+ async getActiveLinks(): Promise {
+ const links = await this.page.locator("nav a.active").all();
+ const texts: string[] = [];
+ for (const link of links) {
+ const text = await link.textContent();
+ if (text) texts.push(text.trim());
+ }
+ return texts;
+ }
+}
+
+export const test = base.extend<{
+ basic: NavLinkPage;
+ nestedRoutes: NavLinkPage;
+ bootstrap: NavLinkPage;
+ tailwind: NavLinkPage;
+}>({
+ basic: async ({ page }, use) => {
+ await use(new NavLinkPage(page, "basic"));
+ },
+ nestedRoutes: async ({ page }, use) => {
+ await use(new NavLinkPage(page, "nested-routes"));
+ },
+ bootstrap: async ({ page }, use) => {
+ await use(new NavLinkPage(page, "bootstrap"));
+ },
+ tailwind: async ({ page }, use) => {
+ await use(new NavLinkPage(page, "tailwind"));
+ },
+});
+
+export { expect } from "@playwright/test";
diff --git a/e2e/tests/nested-routes.spec.ts b/e2e/tests/nested-routes.spec.ts
new file mode 100644
index 0000000..bc2eb02
--- /dev/null
+++ b/e2e/tests/nested-routes.spec.ts
@@ -0,0 +1,137 @@
+import { test, expect } from "./fixtures";
+
+test.describe("Nested Routes Example", () => {
+ test.beforeEach(async ({ nestedRoutes }) => {
+ await nestedRoutes.goto();
+ });
+
+ test("renders main navigation", async ({ nestedRoutes }) => {
+ await expect(nestedRoutes.mainNavLink("Home")).toBeVisible();
+ await expect(nestedRoutes.mainNavLink("Documentation")).toBeVisible();
+ await expect(nestedRoutes.mainNavLink("Blog")).toBeVisible();
+ });
+
+ test("Home is active on initial load", async ({ nestedRoutes }) => {
+ await expect(nestedRoutes.mainNavLink("Home")).toHaveClass(/active/);
+ await expect(nestedRoutes.heading()).toHaveText("Nested Routes Example");
+ });
+
+ test.describe("Documentation Section", () => {
+ test.beforeEach(async ({ nestedRoutes }) => {
+ await nestedRoutes.mainNavLink("Documentation").click();
+ await nestedRoutes.page.waitForLoadState("networkidle");
+ });
+
+ test("shows sub-navigation", async ({ nestedRoutes }) => {
+ await expect(nestedRoutes.subNavLink("Overview")).toBeVisible();
+ await expect(nestedRoutes.subNavLink("Getting Started")).toBeVisible();
+ await expect(nestedRoutes.subNavLink("API Reference")).toBeVisible();
+ });
+
+ test("main nav Documentation stays active", async ({ nestedRoutes }) => {
+ await expect(nestedRoutes.mainNavLink("Documentation")).toHaveClass(
+ /active/
+ );
+ await expect(nestedRoutes.mainNavLink("Home")).not.toHaveClass(/active/);
+ });
+
+ test("Overview is active by default", async ({ nestedRoutes }) => {
+ await expect(nestedRoutes.subNavLink("Overview")).toHaveClass(/active/);
+ await expect(nestedRoutes.heading()).toHaveText("Documentation Overview");
+ });
+
+ test("navigates to Getting Started", async ({ nestedRoutes }) => {
+ await nestedRoutes.subNavLink("Getting Started").click();
+ await nestedRoutes.page.waitForLoadState("networkidle");
+
+ await expect(nestedRoutes.subNavLink("Getting Started")).toHaveClass(
+ /active/
+ );
+ await expect(nestedRoutes.subNavLink("Overview")).not.toHaveClass(
+ /active/
+ );
+ await expect(nestedRoutes.heading()).toHaveText("Getting Started");
+ });
+
+ test("navigates to API Reference", async ({ nestedRoutes }) => {
+ await nestedRoutes.subNavLink("API Reference").click();
+ await nestedRoutes.page.waitForLoadState("networkidle");
+
+ await expect(nestedRoutes.subNavLink("API Reference")).toHaveClass(
+ /active/
+ );
+ await expect(nestedRoutes.heading()).toHaveText("API Reference");
+ });
+ });
+
+ test.describe("Blog Section", () => {
+ test.beforeEach(async ({ nestedRoutes }) => {
+ await nestedRoutes.mainNavLink("Blog").click();
+ await nestedRoutes.page.waitForLoadState("networkidle");
+ });
+
+ test("shows sub-navigation", async ({ nestedRoutes }) => {
+ await expect(nestedRoutes.subNavLink("Latest Posts")).toBeVisible();
+ await expect(nestedRoutes.subNavLink("Archive")).toBeVisible();
+ await expect(nestedRoutes.subNavLink("Categories")).toBeVisible();
+ });
+
+ test("main nav Blog stays active", async ({ nestedRoutes }) => {
+ await expect(nestedRoutes.mainNavLink("Blog")).toHaveClass(/active/);
+ await expect(nestedRoutes.mainNavLink("Documentation")).not.toHaveClass(
+ /active/
+ );
+ });
+
+ test("Latest Posts is active by default", async ({ nestedRoutes }) => {
+ await expect(nestedRoutes.subNavLink("Latest Posts")).toHaveClass(
+ /active/
+ );
+ await expect(nestedRoutes.heading()).toHaveText("Latest Posts");
+ });
+
+ test("navigates through all blog pages", async ({ nestedRoutes }) => {
+ await nestedRoutes.subNavLink("Archive").click();
+ await nestedRoutes.page.waitForLoadState("networkidle");
+ await expect(nestedRoutes.subNavLink("Archive")).toHaveClass(/active/);
+ await expect(nestedRoutes.heading()).toHaveText("Archive");
+
+ await nestedRoutes.subNavLink("Categories").click();
+ await nestedRoutes.page.waitForLoadState("networkidle");
+ await expect(nestedRoutes.subNavLink("Categories")).toHaveClass(/active/);
+ await expect(nestedRoutes.heading()).toHaveText("Categories");
+ });
+ });
+
+ test("switching between sections updates both navs", async ({
+ nestedRoutes,
+ }) => {
+ await nestedRoutes.mainNavLink("Documentation").click();
+ await nestedRoutes.page.waitForLoadState("networkidle");
+ await expect(nestedRoutes.mainNavLink("Documentation")).toHaveClass(
+ /active/
+ );
+ await expect(nestedRoutes.subNavLink("Overview")).toHaveClass(/active/);
+
+ await nestedRoutes.mainNavLink("Blog").click();
+ await nestedRoutes.page.waitForLoadState("networkidle");
+ await expect(nestedRoutes.mainNavLink("Blog")).toHaveClass(/active/);
+ await expect(nestedRoutes.mainNavLink("Documentation")).not.toHaveClass(
+ /active/
+ );
+ await expect(nestedRoutes.subNavLink("Latest Posts")).toHaveClass(/active/);
+ });
+
+ test("direct URL to nested route works", async ({ nestedRoutes }) => {
+ await nestedRoutes.goto("/docs/api");
+
+ await expect(nestedRoutes.subNavLink("API Reference")).toHaveClass(/active/);
+ await expect(nestedRoutes.heading()).toHaveText("API Reference");
+ });
+
+ test("handles 404 page", async ({ nestedRoutes }) => {
+ await nestedRoutes.goto("/nonexistent");
+
+ await expect(nestedRoutes.heading()).toHaveText("404 - Not Found");
+ });
+});
diff --git a/e2e/tests/tailwind.spec.ts b/e2e/tests/tailwind.spec.ts
new file mode 100644
index 0000000..19284b2
--- /dev/null
+++ b/e2e/tests/tailwind.spec.ts
@@ -0,0 +1,119 @@
+import { test, expect } from "./fixtures";
+
+test.describe("Tailwind Example", () => {
+ test.beforeEach(async ({ tailwind }) => {
+ await tailwind.goto();
+ });
+
+ test("renders sidebar navigation", async ({ tailwind }) => {
+ await expect(tailwind.page.locator("aside")).toBeVisible();
+ await expect(
+ tailwind.page.locator('aside .text-xl:has-text("Dashboard")')
+ ).toBeVisible();
+ });
+
+ test("renders all navigation links", async ({ tailwind }) => {
+ await expect(tailwind.navLink("Dashboard")).toBeVisible();
+ await expect(tailwind.navLink("Analytics")).toBeVisible();
+ await expect(tailwind.navLink("Settings")).toBeVisible();
+ });
+
+ test("Dashboard is active on initial load", async ({ tailwind }) => {
+ await expect(tailwind.navLink("Dashboard")).toHaveClass(/active/);
+ await expect(tailwind.heading()).toHaveText("Dashboard");
+ });
+
+ test("displays dashboard stats cards", async ({ tailwind }) => {
+ await expect(tailwind.page.locator("text=Users")).toBeVisible();
+ await expect(tailwind.page.locator("text=1,234")).toBeVisible();
+ await expect(tailwind.page.locator("text=Revenue")).toBeVisible();
+ await expect(tailwind.page.locator("text=$12,345")).toBeVisible();
+ await expect(tailwind.page.locator("text=Orders")).toBeVisible();
+ await expect(tailwind.page.locator("text=567")).toBeVisible();
+ });
+
+ test("NavLinks have nav-link class", async ({ tailwind }) => {
+ const links = await tailwind.page.locator("nav a").all();
+
+ for (const link of links) {
+ await expect(link).toHaveClass(/nav-link/);
+ }
+ });
+
+ test("navigates to Analytics", async ({ tailwind }) => {
+ await tailwind.clickNav("Analytics");
+
+ await expect(tailwind.navLink("Analytics")).toHaveClass(/active/);
+ await expect(tailwind.navLink("Dashboard")).not.toHaveClass(/active/);
+ await expect(tailwind.heading()).toHaveText("Analytics");
+ });
+
+ test("navigates to Settings", async ({ tailwind }) => {
+ await tailwind.clickNav("Settings");
+
+ await expect(tailwind.navLink("Settings")).toHaveClass(/active/);
+ await expect(tailwind.navLink("Dashboard")).not.toHaveClass(/active/);
+ await expect(tailwind.heading()).toHaveText("Settings");
+ });
+
+ test("full navigation cycle", async ({ tailwind }) => {
+ await tailwind.clickNav("Analytics");
+ await expect(tailwind.navLink("Analytics")).toHaveClass(/active/);
+
+ await tailwind.clickNav("Settings");
+ await expect(tailwind.navLink("Settings")).toHaveClass(/active/);
+
+ await tailwind.clickNav("Dashboard");
+ await expect(tailwind.navLink("Dashboard")).toHaveClass(/active/);
+ });
+
+ test("direct URL navigation", async ({ tailwind }) => {
+ await tailwind.goto("/analytics");
+
+ await expect(tailwind.navLink("Analytics")).toHaveClass(/active/);
+ await expect(tailwind.heading()).toHaveText("Analytics");
+ });
+
+ test("handles 404 page", async ({ tailwind }) => {
+ await tailwind.goto("/nonexistent");
+
+ await expect(tailwind.page.locator("h1:has-text('404')")).toBeVisible();
+ await expect(tailwind.page.locator("text=Page not found")).toBeVisible();
+ });
+
+ test("browser history navigation", async ({ tailwind }) => {
+ await tailwind.clickNav("Analytics");
+ await tailwind.clickNav("Settings");
+
+ await tailwind.page.goBack();
+ await expect(tailwind.navLink("Analytics")).toHaveClass(/active/);
+
+ await tailwind.page.goBack();
+ await expect(tailwind.navLink("Dashboard")).toHaveClass(/active/);
+
+ await tailwind.page.goForward();
+ await expect(tailwind.navLink("Analytics")).toHaveClass(/active/);
+ });
+
+ test("only one link is active at a time", async ({ tailwind }) => {
+ const routes = ["Dashboard", "Analytics", "Settings"];
+
+ for (const active of routes) {
+ await tailwind.clickNav(active);
+
+ for (const route of routes) {
+ if (route === active) {
+ await expect(tailwind.navLink(route)).toHaveClass(/active/);
+ } else {
+ await expect(tailwind.navLink(route)).not.toHaveClass(/active/);
+ }
+ }
+ }
+ });
+
+ test("layout has correct structure", async ({ tailwind }) => {
+ await expect(tailwind.page.locator(".min-h-screen.flex")).toBeVisible();
+ await expect(tailwind.page.locator("aside.w-64")).toBeVisible();
+ await expect(tailwind.page.locator("main.flex-1")).toBeVisible();
+ });
+});
diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json
new file mode 100644
index 0000000..85836a9
--- /dev/null
+++ b/e2e/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "declaration": false,
+ "noEmit": true,
+ "types": ["node"]
+ },
+ "include": ["**/*.ts"]
+}
From 9d6a6ef30316f30d0f2f4eb54c67e2883a7e6268 Mon Sep 17 00:00:00 2001
From: RAprogramm
Date: Wed, 17 Dec 2025 07:54:50 +0700
Subject: [PATCH 2/3] #13 feat: add partial matching with Match enum
- Add Match enum (Exact, Partial) for path matching strategy
- Add partial prop to NavLink component
- Update nav_link() function to require explicit Match parameter
- Zero-allocation path segment comparison using iterators
- Breaking change: nav_link() now requires Match as third parameter
- Update examples, documentation, and README
- Bump version to 0.4.0
---
Cargo.lock | 2 +-
Cargo.toml | 2 +-
README.md | 106 +++++----
e2e/tests/nested-routes.spec.ts | 8 +
examples/basic/src/main.rs | 4 +-
examples/nested-routes/src/main.rs | 5 +-
src/lib.rs | 90 ++++----
src/nav_link.rs | 351 +++++++++++++++++++----------
8 files changed, 367 insertions(+), 201 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 5a9c4e6..3b2ba19 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1094,7 +1094,7 @@ dependencies = [
[[package]]
name = "yew-nav-link"
-version = "0.3.0"
+version = "0.4.0"
dependencies = [
"wasm-bindgen-test",
"yew",
diff --git a/Cargo.toml b/Cargo.toml
index 9b0d6c6..d48e78d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "yew-nav-link"
-version = "0.3.0"
+version = "0.4.0"
authors = ["RAprogramm "]
edition = "2024"
rust-version = "1.91.0"
diff --git a/README.md b/README.md
index 86b2c88..ef06de5 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,7 @@ Navigation link component for [Yew](https://yew.rs) with automatic active state
- [Usage](#usage)
- [Component Syntax](#component-syntax)
- [Function Syntax](#function-syntax)
+ - [Partial Matching](#partial-matching)
- [CSS Classes](#css-classes)
- [Bootstrap Integration](#bootstrap-integration)
- [Tailwind CSS](#tailwind-css)
@@ -38,16 +39,16 @@ Navigation link component for [Yew](https://yew.rs) with automatic active state
`yew-nav-link` provides a `NavLink` component that wraps Yew Router's `Link` with automatic active state management. When the target route matches the current URL, an `active` CSS class is applied automatically.
-
+
## Installation
```toml
[dependencies]
-yew-nav-link = "0.3"
+yew-nav-link = "0.4"
```
-
+
## Requirements
@@ -56,7 +57,7 @@ yew-nav-link = "0.3"
| yew | 0.22+ |
| yew-router | 0.19+ |
-
+
## Examples
@@ -67,7 +68,7 @@ Full working examples are available in the [examples/](https://github.com/RAprog
| [basic](https://github.com/RAprogramm/yew-nav-link/tree/main/examples/basic) | Simple navigation with Home, About, Contact pages |
| [bootstrap](https://github.com/RAprogramm/yew-nav-link/tree/main/examples/bootstrap) | Integration with Bootstrap 5 navbar |
| [tailwind](https://github.com/RAprogramm/yew-nav-link/tree/main/examples/tailwind) | Sidebar navigation styled with Tailwind CSS |
-| [nested-routes](https://github.com/RAprogramm/yew-nav-link/tree/main/examples/nested-routes) | Multi-level navigation with nested routing |
+| [nested-routes](https://github.com/RAprogramm/yew-nav-link/tree/main/examples/nested-routes) | Multi-level navigation with partial matching |
### Running Examples
@@ -83,7 +84,7 @@ trunk serve
Open http://127.0.0.1:8080 in your browser.
-
+
## Usage
@@ -102,18 +103,6 @@ enum Route {
About,
}
-#[component]
-fn App() -> Html {
- html! {
-
-
-
- render={switch} />
-
-
- }
-}
-
#[component]
fn Navigation() -> Html {
html! {
@@ -123,44 +112,65 @@ fn Navigation() -> Html {
}
}
-
-fn switch(route: Route) -> Html {
- match route {
- Route::Home => html! { { "Home" } },
- Route::About => html! { { "About" } },
- }
-}
```
### Function Syntax
-For text-only links, use the `nav_link` helper:
+For text-only links, use `nav_link` with explicit `Match` mode:
```rust
use yew::prelude::*;
-use yew_nav_link::nav_link;
+use yew_nav_link::{nav_link, Match};
use yew_router::prelude::*;
#[derive(Clone, PartialEq, Routable)]
enum Route {
#[at("/")]
Home,
- #[at("/about")]
- About,
+ #[at("/docs")]
+ Docs,
}
#[component]
fn Menu() -> Html {
html! {
-
- { nav_link(Route::Home, "Home") }
- { nav_link(Route::About, "About") }
-
+
+ { nav_link(Route::Home, "Home", Match::Exact) }
+ { nav_link(Route::Docs, "Docs", Match::Partial) }
+
}
}
```
-
+### Partial Matching
+
+Use `partial` prop to keep parent links active on nested routes:
+
+```rust
+use yew::prelude::*;
+use yew_nav_link::NavLink;
+use yew_router::prelude::*;
+
+#[derive(Clone, PartialEq, Routable)]
+enum Route {
+ #[at("/docs")]
+ Docs,
+ #[at("/docs/api")]
+ DocsApi,
+}
+
+#[component]
+fn Navigation() -> Html {
+ html! {
+
+ // Active on /docs, /docs/api, /docs/*
+ to={Route::Docs} partial=true>{ "Docs" } >
+
+ }
+}
+```
+
+
## CSS Classes
@@ -193,26 +203,34 @@ The component applies these classes to the rendered `` element:
}
```
-
+
## API Reference
### `NavLink` Component
-| Prop | Type | Description |
-|------|------|-------------|
-| `to` | `R: Routable` | Target route |
-| `children` | `Children` | Link content |
+| Prop | Type | Default | Description |
+|------|------|---------|-------------|
+| `to` | `R: Routable` | required | Target route |
+| `children` | `Children` | required | Link content |
+| `partial` | `bool` | `false` | Enable prefix matching |
+
+### `Match` Enum
+
+| Variant | Description |
+|---------|-------------|
+| `Match::Exact` | Active only on exact path match |
+| `Match::Partial` | Active when current path starts with target |
### `nav_link` Function
```rust
-fn nav_link(to: R, children: &str) -> Html
+fn nav_link(to: R, children: &str, match_mode: Match) -> Html
```
-Creates a `NavLink` with text content.
+Creates a `NavLink` with text content and specified match mode.
-
+
Coverage
@@ -249,7 +267,7 @@ The top section represents the entire project. Proceeding with folders and final
-
+
@@ -257,4 +275,4 @@ The top section represents the entire project. Proceeding with folders and final
Licensed under the [MIT License](LICENSE-MIT).
-
+
diff --git a/e2e/tests/nested-routes.spec.ts b/e2e/tests/nested-routes.spec.ts
index bc2eb02..f373e01 100644
--- a/e2e/tests/nested-routes.spec.ts
+++ b/e2e/tests/nested-routes.spec.ts
@@ -50,6 +50,10 @@ test.describe("Nested Routes Example", () => {
await expect(nestedRoutes.subNavLink("Overview")).not.toHaveClass(
/active/
);
+ // Main nav Documentation stays active due to partial matching
+ await expect(nestedRoutes.mainNavLink("Documentation")).toHaveClass(
+ /active/
+ );
await expect(nestedRoutes.heading()).toHaveText("Getting Started");
});
@@ -125,6 +129,10 @@ test.describe("Nested Routes Example", () => {
test("direct URL to nested route works", async ({ nestedRoutes }) => {
await nestedRoutes.goto("/docs/api");
+ // Main nav stays active due to partial matching
+ await expect(nestedRoutes.mainNavLink("Documentation")).toHaveClass(
+ /active/
+ );
await expect(nestedRoutes.subNavLink("API Reference")).toHaveClass(/active/);
await expect(nestedRoutes.heading()).toHaveText("API Reference");
});
diff --git a/examples/basic/src/main.rs b/examples/basic/src/main.rs
index 5a21d9f..7fedd66 100644
--- a/examples/basic/src/main.rs
+++ b/examples/basic/src/main.rs
@@ -3,7 +3,7 @@
//! Run with: `trunk serve` from the examples/basic directory.
use yew::prelude::*;
-use yew_nav_link::{NavLink, nav_link};
+use yew_nav_link::{Match, NavLink, nav_link};
use yew_router::prelude::*;
/// Application routes.
@@ -47,7 +47,7 @@ fn Navigation() -> Html {
to={Route::About}>{ "About" } >
// Method 2: Function syntax (convenient for text-only links)
- { nav_link(Route::Contact, "Contact") }
+ { nav_link(Route::Contact, "Contact", Match::Exact) }
}
diff --git a/examples/nested-routes/src/main.rs b/examples/nested-routes/src/main.rs
index a7d5497..7d0b260 100644
--- a/examples/nested-routes/src/main.rs
+++ b/examples/nested-routes/src/main.rs
@@ -67,14 +67,15 @@ fn App() -> Html {
}
/// Main navigation bar.
+/// Uses `partial=true` so section links stay active on nested routes.
#[component]
fn MainNav() -> Html {
html! {
to={Route::Home}>{ "Home" } >
- to={Route::DocsRoot}>{ "Documentation" } >
- to={Route::BlogRoot}>{ "Blog" } >
+ to={Route::DocsRoot} partial=true>{ "Documentation" } >
+ to={Route::BlogRoot} partial=true>{ "Blog" } >
}
diff --git a/src/lib.rs b/src/lib.rs
index e1c7395..ea1aea4 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,7 +1,7 @@
//! # yew-nav-link
//!
-//! A navigation link component for [Yew](https://yew.rs) applications with
-//! automatic active state detection based on the current route.
+//! Navigation link component for [Yew](https://yew.rs) with automatic active
+//! state detection.
//!
//! [](https://crates.io/crates/yew-nav-link)
//! [](https://docs.rs/yew-nav-link)
@@ -10,20 +10,17 @@
//! ## Overview
//!
//! `yew-nav-link` provides a [`NavLink`] component that wraps Yew Router's
-//! `Link` component with automatic active state management. When the link's
-//! target route matches the current URL, an `active` CSS class is automatically
-//! applied.
+//! `Link` with automatic active state management. When the target route matches
+//! the current URL, an `active` CSS class is applied.
//!
//! ## Quick Start
//!
-//! Add to your `Cargo.toml`:
-//!
//! ```toml
//! [dependencies]
-//! yew-nav-link = "0.3"
+//! yew-nav-link = "0.4"
//! ```
//!
-//! ## Usage
+//! ## Component Syntax
//!
//! ```rust
//! use yew::prelude::*;
@@ -39,55 +36,76 @@
//! }
//!
//! #[component]
-//! fn App() -> Html {
+//! fn Navigation() -> Html {
//! html! {
-//!
-//!
-//! to={Route::Home}>{ "Home" } >
-//! to={Route::About}>{ "About" } >
-//!
-//! render={switch} />
-//!
-//! }
-//! }
-//!
-//! fn switch(route: Route) -> Html {
-//! match route {
-//! Route::Home => html! { { "Home" } },
-//! Route::About => html! { { "About" } }
+//!
+//! to={Route::Home}>{ "Home" } >
+//! to={Route::About}>{ "About" } >
+//!
//! }
//! }
//! ```
//!
-//! ## Helper Function
+//! ## Function Syntax
//!
-//! For text-only links, use the [`nav_link`] helper function:
+//! For text-only links, use [`nav_link`] with explicit [`Match`] mode:
//!
//! ```rust
//! use yew::prelude::*;
-//! use yew_nav_link::nav_link;
+//! use yew_nav_link::{Match, nav_link};
//! use yew_router::prelude::*;
//!
//! # #[derive(Clone, PartialEq, Debug, Routable)]
//! # enum Route {
//! # #[at("/")]
//! # Home,
+//! # #[at("/docs")]
+//! # Docs,
//! # }
//! #[component]
//! fn Menu() -> Html {
//! html! {
-//!
-//! { nav_link(Route::Home, "Home") }
-//!
+//!
+//! { nav_link(Route::Home, "Home", Match::Exact) }
+//! { nav_link(Route::Docs, "Docs", Match::Partial) }
+//!
+//! }
+//! }
+//! ```
+//!
+//! ## Partial Matching
+//!
+//! Use `partial` prop to keep parent links active on nested routes:
+//!
+//! ```rust
+//! use yew::prelude::*;
+//! use yew_nav_link::NavLink;
+//! use yew_router::prelude::*;
+//!
+//! # #[derive(Clone, PartialEq, Debug, Routable)]
+//! # enum Route {
+//! # #[at("/docs")]
+//! # Docs,
+//! # #[at("/docs/api")]
+//! # DocsApi,
+//! # }
+//! #[component]
+//! fn Navigation() -> Html {
+//! html! {
+//!
+//! // Active on /docs, /docs/api, /docs/*
+//! to={Route::Docs} partial=true>{ "Docs" } >
+//!
//! }
//! }
//! ```
//!
//! ## CSS Classes
//!
-//! The rendered `` element receives:
-//! - `nav-link` - always applied
-//! - `active` - applied when route matches current URL
+//! | Class | Condition |
+//! |-------|-----------|
+//! | `nav-link` | Always |
+//! | `active` | Route matches |
//!
//! Compatible with Bootstrap, Tailwind, and other CSS frameworks.
//!
@@ -95,11 +113,7 @@
//!
//! - Yew 0.22+
//! - yew-router 0.19+
-//!
-//! ## License
-//!
-//! Licensed under the MIT License. See [LICENSE-MIT](LICENSE-MIT) for details.
mod nav_link;
-pub use nav_link::{NavLink, NavLinkProps, nav_link};
+pub use nav_link::{Match, NavLink, NavLinkProps, nav_link};
diff --git a/src/nav_link.rs b/src/nav_link.rs
index ff54cbd..cede33a 100644
--- a/src/nav_link.rs
+++ b/src/nav_link.rs
@@ -18,126 +18,114 @@
//!
//! # CSS Classes
//!
-//! The component applies the following CSS classes to the rendered ` `
-//! element:
-//!
//! | Class | Condition |
//! |-------|-----------|
//! | `nav-link` | Always applied |
//! | `active` | Applied when the target route matches the current route |
//!
-//! # Usage
+//! # Match Modes
+//!
+//! NavLink supports two matching modes via the `partial` prop:
//!
-//! ## Component Syntax
+//! - **Exact** (default): Link is active only when paths match exactly
+//! - **Partial**: Link is active when current path starts with target path
//!
//! ```rust
//! use yew::prelude::*;
//! use yew_nav_link::NavLink;
//! use yew_router::prelude::*;
//!
-//! #[derive(Clone, PartialEq, Debug, Routable)]
+//! #[derive(Clone, PartialEq, Routable)]
//! enum Route {
-//! #[at("/")]
-//! Home,
-//! #[at("/about")]
-//! About
+//! #[at("/docs")]
+//! Docs,
+//! #[at("/docs/api")]
+//! DocsApi
//! }
//!
//! #[component]
//! fn Navigation() -> Html {
//! html! {
//!
-//! to={Route::Home}>{ "Home" } >
-//! to={Route::About}>{ "About" } >
+//! // Exact: active only on /docs
+//! to={Route::Docs}>{ "Docs" } >
+//! // Partial: active on /docs, /docs/api, /docs/*
+//! to={Route::Docs} partial=true>{ "Docs" } >
//!
//! }
//! }
//! ```
//!
-//! ## Function Syntax
+//! # Function Syntax
//!
-//! For simpler cases with text-only children:
+//! For text-only links, use [`nav_link`] with explicit [`Match`] mode:
//!
//! ```rust
//! use yew::prelude::*;
-//! use yew_nav_link::nav_link;
+//! use yew_nav_link::{Match, nav_link};
//! use yew_router::prelude::*;
//!
//! #[derive(Clone, PartialEq, Debug, Routable)]
//! enum Route {
//! #[at("/")]
//! Home,
-//! #[at("/about")]
-//! About
+//! #[at("/docs")]
+//! Docs
//! }
//!
//! #[component]
//! fn Navigation() -> Html {
//! html! {
//!
-//! { nav_link(Route::Home, "Home") }
-//! { nav_link(Route::About, "About") }
+//! { nav_link(Route::Home, "Home", Match::Exact) }
+//! { nav_link(Route::Docs, "Docs", Match::Partial) }
//!
//! }
//! }
//! ```
-//!
-//! # Integration with CSS Frameworks
-//!
-//! The component works seamlessly with Bootstrap, Tailwind, and other CSS
-//! frameworks that use the `.nav-link` and `.active` class conventions:
-//!
-//! ```html
-//!
-//!
-//! ```
use std::marker::PhantomData;
use yew::prelude::*;
use yew_router::prelude::*;
+/// Path matching strategy for NavLink active state detection.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum Match {
+ /// Link is active only when paths match exactly.
+ #[default]
+ Exact,
+ /// Link is active when current path starts with target path (segment-wise).
+ Partial
+}
+
/// Properties for the [`NavLink`] component.
-///
-/// # Type Parameters
-///
-/// * `R` - A type implementing [`Routable`] that defines the target route.
#[derive(Properties, PartialEq, Debug)]
pub struct NavLinkProps {
/// Target route for navigation.
- ///
- /// When clicked, the application navigates to this route.
- /// The component compares this value against the current route
- /// to determine active state.
pub to: R,
/// Content rendered inside the link element.
- ///
- /// Accepts any valid Yew children: text, HTML elements, or components.
pub children: Children,
+ /// Enable partial (prefix) path matching.
+ ///
+ /// When `false` (default), the link is active only on exact path match.
+ /// When `true`, the link is active if current path starts with target path.
+ #[prop_or(false)]
+ pub partial: bool,
+
#[prop_or_default]
pub(crate) _marker: PhantomData
}
/// Navigation link with automatic active state detection.
///
-/// Wraps Yew Router's [`Link`] component and automatically applies the `active`
-/// CSS class when the target route matches the current URL.
-///
/// # CSS Classes
///
/// - `nav-link` - Always applied
/// - `active` - Applied when route matches current URL
///
-/// # Type Parameters
-///
-/// * `R` - Route type implementing [`Routable`]
-///
/// # Example
///
/// ```rust
@@ -166,84 +154,98 @@ pub struct NavLinkProps {
#[component]
pub fn NavLink(props: &NavLinkProps) -> Html {
let current_route = use_route::();
- let is_active = current_route.is_some_and(|route| route == props.to);
- let class = build_class(is_active);
+ let is_active = current_route.is_some_and(|route| {
+ if props.partial {
+ is_path_prefix(&props.to.to_path(), &route.to_path())
+ } else {
+ route == props.to
+ }
+ });
+
html! {
- to={props.to.clone()} classes={classes!(class)}>
- for child in props.children.iter() {
- { child }
- }
+ to={props.to.clone()} classes={classes!(build_class(is_active))}>
+ { for props.children.iter() }
>
}
}
-/// Creates a NavLink component for the specified route with the provided
-/// children.
-///
-/// This function creates a NavLink component for Yew applications using Yew
-/// Router. It takes a route (`R`) and children text, and returns a NavLink
-/// component.
+/// Creates a NavLink with the specified match mode.
///
/// # Arguments
///
-/// * `to` - The destination route for the link.
-/// * `children` - The text or other elements to be rendered within the link.
+/// * `to` - Target route
+/// * `children` - Link text
+/// * `match_mode` - [`Match::Exact`] or [`Match::Partial`]
///
/// # Example
///
/// ```rust
/// use yew::prelude::*;
-/// use yew_nav_link::{NavLink, nav_link};
+/// use yew_nav_link::{Match, nav_link};
/// use yew_router::prelude::*;
///
/// #[derive(Clone, PartialEq, Debug, Routable)]
-/// enum HomeRoute {
+/// enum Route {
/// #[at("/")]
-/// IntroPage,
-/// #[at("/about")]
-/// About
+/// Home,
+/// #[at("/docs")]
+/// Docs
/// }
///
/// #[component]
/// fn Menu() -> Html {
/// html! {
-///
-/// // Creating a NavLink for the Home route with the text "Home Page"
-///
-/// { nav_link(HomeRoute::IntroPage, "Home Page") }
-///
-///
-/// { nav_link(HomeRoute::About, "About") }
-///
-///
+///
+/// { nav_link(Route::Home, "Home", Match::Exact) }
+/// { nav_link(Route::Docs, "Docs", Match::Partial) }
+///
/// }
/// }
/// ```
+pub fn nav_link(
+ to: R,
+ children: &str,
+ match_mode: Match
+) -> Html {
+ let partial = match_mode == Match::Partial;
+ html! {
+ to={to} {partial}>{ Html::from(children) } >
+ }
+}
+
+/// Checks if `target` path is a segment-wise prefix of `current` path.
///
-/// # Generic Type
-///
-/// * `R` - The route type that implements the `Routable` trait.
-///
-/// # Returns
-///
-/// An HTML representation of the NavLink component.
+/// Uses iterators without heap allocation for efficiency during renders.
///
-/// # Note
+/// # Examples
///
-/// The `to` parameter must be of a type that implements the `Routable` trait.
-pub fn nav_link(to: R, children: &str) -> Html {
- html! {
- to={to}>{ Html::from(children) } >
+/// ```text
+/// is_path_prefix("/docs", "/docs/api") -> true
+/// is_path_prefix("/docs", "/docs") -> true
+/// is_path_prefix("/doc", "/documents") -> false (segment boundary)
+/// is_path_prefix("/", "/anything") -> true
+/// ```
+#[inline]
+fn is_path_prefix(target: &str, current: &str) -> bool {
+ let mut target_iter = target.split('/').filter(|s| !s.is_empty());
+ let mut current_iter = current.split('/').filter(|s| !s.is_empty());
+
+ loop {
+ match (target_iter.next(), current_iter.next()) {
+ (Some(t), Some(c)) if t == c => continue,
+ (Some(_), Some(_)) => return false,
+ (Some(_), None) => return false,
+ (None, _) => return true
+ }
}
}
-/// Generates CSS class string based on active state.
#[inline]
-fn build_class(is_active: bool) -> String {
+fn build_class(is_active: bool) -> &'static str {
if is_active {
- "nav-link active".to_string()
+ "nav-link active"
} else {
- "nav-link".to_string()
+ "nav-link"
}
}
@@ -256,9 +258,40 @@ mod tests {
#[at("/")]
Home,
#[at("/about")]
- About
+ About,
+ #[at("/docs")]
+ Docs,
+ #[at("/docs/api")]
+ DocsApi
+ }
+
+ // Match enum tests
+ #[test]
+ fn match_default_is_exact() {
+ assert_eq!(Match::default(), Match::Exact);
+ }
+
+ #[test]
+ fn match_equality() {
+ assert_eq!(Match::Exact, Match::Exact);
+ assert_eq!(Match::Partial, Match::Partial);
+ assert_ne!(Match::Exact, Match::Partial);
+ }
+
+ #[test]
+ fn match_debug() {
+ assert_eq!(format!("{:?}", Match::Exact), "Exact");
+ assert_eq!(format!("{:?}", Match::Partial), "Partial");
+ }
+
+ #[test]
+ fn match_clone() {
+ let m = Match::Partial;
+ let cloned = m;
+ assert_eq!(m, cloned);
}
+ // build_class tests
#[test]
fn build_class_active() {
assert_eq!(build_class(true), "nav-link active");
@@ -269,77 +302,169 @@ mod tests {
assert_eq!(build_class(false), "nav-link");
}
+ // NavLinkProps tests
#[test]
- fn props_equality_same_route() {
+ fn props_equality_same() {
let props1: NavLinkProps = NavLinkProps {
to: TestRoute::Home,
children: Default::default(),
+ partial: false,
_marker: PhantomData
};
let props2: NavLinkProps = NavLinkProps {
to: TestRoute::Home,
children: Default::default(),
+ partial: false,
_marker: PhantomData
};
assert_eq!(props1, props2);
}
#[test]
- fn props_equality_different_routes() {
+ fn props_equality_different_route() {
let props1: NavLinkProps = NavLinkProps {
to: TestRoute::Home,
children: Default::default(),
+ partial: false,
_marker: PhantomData
};
let props2: NavLinkProps = NavLinkProps {
to: TestRoute::About,
children: Default::default(),
+ partial: false,
+ _marker: PhantomData
+ };
+ assert_ne!(props1, props2);
+ }
+
+ #[test]
+ fn props_equality_different_partial() {
+ let props1: NavLinkProps = NavLinkProps {
+ to: TestRoute::Home,
+ children: Default::default(),
+ partial: false,
+ _marker: PhantomData
+ };
+ let props2: NavLinkProps = NavLinkProps {
+ to: TestRoute::Home,
+ children: Default::default(),
+ partial: true,
_marker: PhantomData
};
assert_ne!(props1, props2);
}
#[test]
- fn props_debug_impl() {
+ fn props_debug() {
let props: NavLinkProps = NavLinkProps {
to: TestRoute::Home,
children: Default::default(),
+ partial: false,
_marker: PhantomData
};
- let debug_str = format!("{:?}", props);
- assert!(debug_str.contains("NavLinkProps"));
- assert!(debug_str.contains("Home"));
+ let debug = format!("{:?}", props);
+ assert!(debug.contains("NavLinkProps"));
+ assert!(debug.contains("Home"));
}
+ // nav_link function tests
#[test]
- fn nav_link_fn_returns_html() {
- let html = nav_link(TestRoute::Home, "Home");
+ fn nav_link_exact_returns_html() {
+ let html = nav_link(TestRoute::Home, "Home", Match::Exact);
assert!(matches!(html, Html::VComp(_)));
}
#[test]
- fn nav_link_fn_different_routes() {
- let html1 = nav_link(TestRoute::Home, "Home");
- let html2 = nav_link(TestRoute::About, "About");
- assert!(matches!(html1, Html::VComp(_)));
- assert!(matches!(html2, Html::VComp(_)));
+ fn nav_link_partial_returns_html() {
+ let html = nav_link(TestRoute::Docs, "Docs", Match::Partial);
+ assert!(matches!(html, Html::VComp(_)));
}
#[test]
- fn nav_link_fn_empty_text() {
- let html = nav_link(TestRoute::Home, "");
- assert!(matches!(html, Html::VComp(_)));
+ fn nav_link_different_routes() {
+ let h1 = nav_link(TestRoute::Home, "Home", Match::Exact);
+ let h2 = nav_link(TestRoute::About, "About", Match::Exact);
+ assert!(matches!(h1, Html::VComp(_)));
+ assert!(matches!(h2, Html::VComp(_)));
}
#[test]
- fn nav_link_fn_long_text() {
- let html = nav_link(TestRoute::Home, "This is a very long navigation link text");
+ fn nav_link_empty_text() {
+ let html = nav_link(TestRoute::Home, "", Match::Exact);
assert!(matches!(html, Html::VComp(_)));
}
+ // is_path_prefix tests - exact matches
+ #[test]
+ fn prefix_exact_match() {
+ assert!(is_path_prefix("/", "/"));
+ assert!(is_path_prefix("/docs", "/docs"));
+ assert!(is_path_prefix("/docs/api", "/docs/api"));
+ }
+
+ // is_path_prefix tests - valid prefixes
+ #[test]
+ fn prefix_valid() {
+ assert!(is_path_prefix("/docs", "/docs/api"));
+ assert!(is_path_prefix("/docs", "/docs/api/ref"));
+ assert!(is_path_prefix("/a", "/a/b/c/d"));
+ }
+
+ #[test]
+ fn prefix_root_matches_all() {
+ assert!(is_path_prefix("/", "/docs"));
+ assert!(is_path_prefix("/", "/docs/api"));
+ assert!(is_path_prefix("/", "/any/path/here"));
+ }
+
+ // is_path_prefix tests - not prefixes
+ #[test]
+ fn prefix_not_prefix() {
+ assert!(!is_path_prefix("/docs/api", "/docs"));
+ assert!(!is_path_prefix("/about", "/docs"));
+ assert!(!is_path_prefix("/a/b/c", "/a/b"));
+ }
+
+ #[test]
+ fn prefix_segment_boundary() {
+ assert!(!is_path_prefix("/doc", "/documents"));
+ assert!(!is_path_prefix("/api", "/api-v2"));
+ assert!(!is_path_prefix("/user", "/users"));
+ }
+
+ // is_path_prefix tests - edge cases
+ #[test]
+ fn prefix_trailing_slashes() {
+ assert!(is_path_prefix("/docs/", "/docs/api"));
+ assert!(is_path_prefix("/docs", "/docs/api/"));
+ assert!(is_path_prefix("/docs/", "/docs/"));
+ }
+
+ #[test]
+ fn prefix_multiple_slashes() {
+ assert!(is_path_prefix("/docs//", "/docs/api"));
+ assert!(is_path_prefix("//docs", "/docs//api"));
+ }
+
+ #[test]
+ fn prefix_empty_paths() {
+ assert!(is_path_prefix("", "/docs"));
+ assert!(is_path_prefix("", ""));
+ assert!(!is_path_prefix("/docs", ""));
+ }
+
+ // Route tests
#[test]
fn route_equality() {
assert_eq!(TestRoute::Home, TestRoute::Home);
assert_ne!(TestRoute::Home, TestRoute::About);
}
+
+ #[test]
+ fn route_to_path() {
+ assert_eq!(TestRoute::Home.to_path(), "/");
+ assert_eq!(TestRoute::About.to_path(), "/about");
+ assert_eq!(TestRoute::Docs.to_path(), "/docs");
+ assert_eq!(TestRoute::DocsApi.to_path(), "/docs/api");
+ }
}
From 5ff14d66d3cffefa102273d0f70557821d31a0d7 Mon Sep 17 00:00:00 2001
From: RAprogramm
Date: Wed, 17 Dec 2025 08:12:24 +0700
Subject: [PATCH 3/3] #13 fix: install rustup in release script
---
.rultor.yml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/.rultor.yml b/.rultor.yml
index 3d057cb..3fafc18 100644
--- a/.rultor.yml
+++ b/.rultor.yml
@@ -13,6 +13,8 @@ release:
commanders:
- RAprogramm
script: |
+ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
+ source $HOME/.cargo/env
rustup toolchain install nightly
rustup component add rustfmt --toolchain nightly
sed -i "s/^version = .*/version = \"${tag}\"/" Cargo.toml