diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9b87ba03..63a5e3a9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -71,7 +71,6 @@ jobs: - uses: browser-actions/setup-firefox@latest - run: cargo test --workspace - fmt: if: github.event.pull_request.draft == false name: Rustfmt diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 00000000..b982c0b1 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,67 @@ +name: Playwright Tests + +on: + push: + branches: + - main + paths: + - /** + - preview/**/*.rs + - preview/**/Cargo.toml + - primitives/**/*.rs + - primitives/**/Cargo.toml + - .github/** + - Cargo.toml + + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + branches: + - main + paths: + - /** + - preview/**/*.rs + - preview/**/Cargo.toml + - primitives/**/*.rs + - primitives/**/Cargo.toml + - .github/** + - Cargo.toml + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test: + if: github.event.pull_request.draft == false + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + # Do our best to cache the toolchain and node install steps + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install Rust + uses: dtolnay/rust-toolchain@1.86.0 + with: + targets: x86_64-unknown-linux-gnu,wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + with: + cache-all-crates: "true" + cache-on-failure: "true" + - name: Clear base path + run: rm -f preview/Dioxus.toml + - name: Install dx + run: cargo install dioxus-cli@0.7.0-alpha.1 + - name: Install dependencies + run: cd ./playwright && npm ci + - name: Install Playwright Browsers + run: cd ./playwright && npx playwright install --with-deps + - name: Run Playwright tests + run: cd ./playwright && npx playwright test + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 48b1e1b1..bc8a3e9b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,11 @@ target **/.claude/settings.local.json +node_modules + +# Playwright +/test-results/ +/playwright/playwright-report/ +/playwright/test-results/ +/blob-report/ +/playwright/.cache/ diff --git a/playwright/package-lock.json b/playwright/package-lock.json new file mode 100644 index 00000000..1447213e --- /dev/null +++ b/playwright/package-lock.json @@ -0,0 +1,216 @@ +{ + "name": "playwright", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@axe-core/playwright": "^4.10.2", + "@playwright/test": "^1.53.0", + "axe-playwright": "^2.1.0", + "playwright": "^1.53.0" + } + }, + "node_modules/@axe-core/playwright": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.10.2.tgz", + "integrity": "sha512-6/b5BJjG6hDaRNtgzLIfKr5DfwyiLHO4+ByTLB0cJgWSM8Ll7KqtdblIS6bEkwSF642/Ex91vNqIl3GLXGlceg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.10.3" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, + "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==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.53.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/junit-report-builder": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/junit-report-builder/-/junit-report-builder-3.0.2.tgz", + "integrity": "sha512-R5M+SYhMbwBeQcNXYWNCZkl09vkVfAtcPIaCGdzIkkbeaTrVbGQ7HVgi4s+EmM/M1K4ZuWQH0jGcvMvNePfxYA==", + "dev": true, + "license": "MIT" + }, + "node_modules/axe-core": { + "version": "4.10.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", + "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axe-html-reporter": { + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/axe-html-reporter/-/axe-html-reporter-2.2.11.tgz", + "integrity": "sha512-WlF+xlNVgNVWiM6IdVrsh+N0Cw7qupe5HT9N6Uyi+aN7f6SSi92RDomiP1noW8OWIV85V6x404m5oKMeqRV3tQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mustache": "^4.0.1" + }, + "engines": { + "node": ">=8.9.0" + }, + "peerDependencies": { + "axe-core": ">=3" + } + }, + "node_modules/axe-playwright": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/axe-playwright/-/axe-playwright-2.1.0.tgz", + "integrity": "sha512-tY48SX56XaAp16oHPyD4DXpybz8Jxdz9P7exTjF/4AV70EGUavk+1fUPWirM0OYBR+YyDx6hUeDvuHVA6fB9YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/junit-report-builder": "^3.0.2", + "axe-core": "^4.10.1", + "axe-html-reporter": "2.2.11", + "junit-report-builder": "^5.1.1", + "picocolors": "^1.1.1" + }, + "peerDependencies": { + "playwright": ">1.0.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/junit-report-builder": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/junit-report-builder/-/junit-report-builder-5.1.1.tgz", + "integrity": "sha512-ZNOIIGMzqCGcHQEA2Q4rIQQ3Df6gSIfne+X9Rly9Bc2y55KxAZu8iGv+n2pP0bLf0XAOctJZgeloC54hWzCahQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "make-dir": "^3.1.0", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "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==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.53.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "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==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + } + } +} diff --git a/playwright/package.json b/playwright/package.json new file mode 100644 index 00000000..3c3b838f --- /dev/null +++ b/playwright/package.json @@ -0,0 +1,8 @@ +{ + "devDependencies": { + "@axe-core/playwright": "^4.10.2", + "@playwright/test": "^1.53.0", + "axe-playwright": "^2.1.0", + "playwright": "^1.53.0" + } +} diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts new file mode 100644 index 00000000..f4491f9d --- /dev/null +++ b/playwright/playwright.config.ts @@ -0,0 +1,76 @@ +import { defineConfig, devices } from "@playwright/test"; +const path = require("path"); + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: ".", + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "html", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + }, + + // Each test is given 20 minutes. + timeout: 20 * 60 * 1000, + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + + /* Test against mobile viewports. */ + { + name: "Mobile Chrome", + use: { ...devices["Pixel 5"] }, + }, + { + name: "Mobile Safari", + use: { ...devices["iPhone 12"] }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + cwd: path.join(process.cwd(), "../preview"), + command: "dx serve --platform web", + port: 8080, + timeout: 50 * 60 * 1000, + reuseExistingServer: !process.env.CI, + stdout: "pipe", + }, +}); diff --git a/playwright/preview.spec.ts b/playwright/preview.spec.ts new file mode 100644 index 00000000..c0766ad6 --- /dev/null +++ b/playwright/preview.spec.ts @@ -0,0 +1,20 @@ +import { test, expect } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; + +test.describe("homepage", () => { + test("should not have any automatically detectable accessibility issues", async ({ + page, + }) => { + await page.goto("http://127.0.0.1:8080/", { timeout: 20 * 60 * 1000 }); // Increase timeout to 20 minutes + + // Wait for the page to fully load + let heroSection = page.locator("#hero"); + await heroSection.waitFor({ state: "visible" }); + + const accessibilityScanResults = await new AxeBuilder({ page }) + .disableRules("color-contrast") + .analyze(); + + expect(accessibilityScanResults.violations).toEqual([]); + }); +}); diff --git a/preview/index.html b/preview/index.html new file mode 100644 index 00000000..f616cc56 --- /dev/null +++ b/preview/index.html @@ -0,0 +1,11 @@ + + + + + + Dioxus Components + + +
+ + diff --git a/preview/src/components/avatar/mod.rs b/preview/src/components/avatar/mod.rs index adf3cd02..dd6de40b 100644 --- a/preview/src/components/avatar/mod.rs +++ b/preview/src/components/avatar/mod.rs @@ -16,6 +16,7 @@ pub(super) fn Demo() -> Element { on_state_change: move |state| { avatar_state.set(format!("Avatar 1: {state:?}")); }, + aria_label: "Basic avatar", AvatarImage { class: "avatar-image", src: "https://avatars.githubusercontent.com/u/66571940?s=96&v=4", @@ -31,6 +32,7 @@ pub(super) fn Demo() -> Element { on_state_change: move |state| { avatar_state.set(format!("Avatar 2: {state:?}")); }, + aria_label: "Error avatar", AvatarImage { class: "avatar-image", src: "https://invalid-url.example/image.jpg", @@ -46,6 +48,7 @@ pub(super) fn Demo() -> Element { on_state_change: move |state| { avatar_state.set(format!("Avatar 4: {state:?}")); }, + aria_label: "Large avatar", AvatarImage { class: "avatar-image", src: asset!("/assets/dioxus-logo.png", ImageAssetOptions::new().with_avif()), diff --git a/preview/src/components/checkbox/mod.rs b/preview/src/components/checkbox/mod.rs index a6a2f760..14754bd4 100644 --- a/preview/src/components/checkbox/mod.rs +++ b/preview/src/components/checkbox/mod.rs @@ -7,7 +7,10 @@ pub(super) fn Demo() -> Element { rel: "stylesheet", href: asset!("/src/components/checkbox/style.css"), } - Checkbox { class: "checkbox", name: "tos-check", + Checkbox { + class: "checkbox", + name: "tos-check", + aria_label: "Demo Checkbox", CheckboxIndicator { class: "checkbox-indicator", svg { diff --git a/preview/src/components/hover_card/mod.rs b/preview/src/components/hover_card/mod.rs index f38ccd5e..adf4514c 100644 --- a/preview/src/components/hover_card/mod.rs +++ b/preview/src/components/hover_card/mod.rs @@ -13,7 +13,7 @@ pub(super) fn Demo() -> Element { style: "padding: 50px; display: flex; flex-direction: row; flex-wrap: wrap; gap: 40px; justify-content: center; align-items: center;", HoverCard { class: "hover-card", HoverCardTrigger { class: "hover-card-trigger", - "@johndoe" + i { "Dioxus" } } HoverCardContent { class: "hover-card-content", side: HoverCardSide::Bottom, div { diff --git a/preview/src/components/progress/mod.rs b/preview/src/components/progress/mod.rs index a0197574..50e65e8c 100644 --- a/preview/src/components/progress/mod.rs +++ b/preview/src/components/progress/mod.rs @@ -23,7 +23,10 @@ pub(super) fn Demo() -> Element { rel: "stylesheet", href: asset!("/src/components/progress/style.css"), } - Progress { class: "progress", value: progress() as f64, + Progress { + aria_label: "Progressbar Demo", + class: "progress", + value: progress() as f64, ProgressIndicator { class: "progress-indicator" } } } diff --git a/preview/src/components/scroll_area/mod.rs b/preview/src/components/scroll_area/mod.rs index 07eeb876..5c23ac84 100644 --- a/preview/src/components/scroll_area/mod.rs +++ b/preview/src/components/scroll_area/mod.rs @@ -14,6 +14,7 @@ pub(super) fn Demo() -> Element { border_radius: "0.5em", padding: "0 1em 1em 1em", direction: ScrollDirection::Vertical, + tabindex: "0", div { class: "scroll-content", for i in 1..=20 { p { diff --git a/preview/src/components/select/mod.rs b/preview/src/components/select/mod.rs index c1fb889c..d5e96bca 100644 --- a/preview/src/components/select/mod.rs +++ b/preview/src/components/select/mod.rs @@ -10,6 +10,7 @@ pub(super) fn Demo() -> Element { Select { class: "select", placeholder: "Select a fruit...", + aria_label: "Select Demo", SelectGroup { label: "Fruits".to_string(), SelectOption { value: "apple".to_string(), "Apple" } SelectOption { value: "banana".to_string(), "Banana" } diff --git a/preview/src/components/slider/mod.rs b/preview/src/components/slider/mod.rs index 31967476..f1bb61a3 100644 --- a/preview/src/components/slider/mod.rs +++ b/preview/src/components/slider/mod.rs @@ -10,10 +10,13 @@ pub(super) fn Demo() -> Element { } Slider { class: "slider", + label: "Demo Slider", horizontal: true, SliderTrack { class: "slider-track", SliderRange { class: "slider-range" } - SliderThumb { class: "slider-thumb" } + SliderThumb { + class: "slider-thumb" + } } } } diff --git a/preview/src/components/switch/mod.rs b/preview/src/components/switch/mod.rs index 0bc3126a..a79bedac 100644 --- a/preview/src/components/switch/mod.rs +++ b/preview/src/components/switch/mod.rs @@ -12,6 +12,7 @@ pub(super) fn Demo() -> Element { Switch { class: "switch", checked: checked(), + aria_label: "Switch Demo", on_checked_change: move |new_checked| { checked.set(new_checked); tracing::info!("Switch toggled: {new_checked}"); diff --git a/preview/src/components/tabs/docs.md b/preview/src/components/tabs/docs.md index ce350f9e..ef44be81 100644 --- a/preview/src/components/tabs/docs.md +++ b/preview/src/components/tabs/docs.md @@ -5,17 +5,22 @@ The Tabs component is used to create a tabbed interface, allowing users to switc ```rust // The Tabs component wraps all tab triggers and contents and orders them based on their index. Tabs { - // The TabTrigger component is used to create a clickable tab button that switches the active tab. - TabTrigger { - // The index of the tab trigger, used to determine the focus order of the tabs. - index: 0, - // The value of the tab trigger, which must be unique and is used to identify the active tab. - value: "tab1", - // The contents of the tab trigger button - {children} + // The TabList component contains all the tab triggers + TabList { + // The TabTrigger component is used to create a clickable tab button that switches the active tab. + TabTrigger { + // The index of the tab trigger, used to determine the focus order of the tabs. + index: 0, + // The value of the tab trigger, which must be unique and is used to identify the active tab. + value: "tab1", + // The contents of the tab trigger button + {children} + } } // The TabContent component contains the content that is displayed when the corresponding tab is active. TabContent { + // The index of the tab content, used to determine the focus order of the tabs. + index: 0, // The value of the tab content, which must match the value of the corresponding TabTrigger to be displayed. value: "tab1", // The content of the tab, which is displayed when the tab is active. diff --git a/preview/src/components/tabs/mod.rs b/preview/src/components/tabs/mod.rs index aef304c7..935ff8a6 100644 --- a/preview/src/components/tabs/mod.rs +++ b/preview/src/components/tabs/mod.rs @@ -1,5 +1,5 @@ use dioxus::prelude::*; -use dioxus_primitives::tabs::{TabContent, TabTrigger, Tabs}; +use dioxus_primitives::tabs::{TabContent, TabTrigger, Tabs, TabList}; #[component] pub(super) fn Demo() -> Element { rsx! { @@ -9,7 +9,7 @@ pub(super) fn Demo() -> Element { default_value: "tab1".to_string(), horizontal: true, max_width: "16rem", - div { class: "tabs-list", + TabList { class: "tabs-list", TabTrigger { class: "tabs-trigger", value: "tab1".to_string(), @@ -29,7 +29,10 @@ pub(super) fn Demo() -> Element { "Tab 3" } } - TabContent { class: "tabs-content", value: "tab1".to_string(), + TabContent { + index: 0usize, + class: "tabs-content", + value: "tab1".to_string(), div { width: "100%", height: "5rem", @@ -39,7 +42,10 @@ pub(super) fn Demo() -> Element { "Tab 1 Content" } } - TabContent { class: "tabs-content", value: "tab2".to_string(), + TabContent { + index: 1usize, + class: "tabs-content", + value: "tab2".to_string(), div { width: "100%", height: "5rem", @@ -49,7 +55,10 @@ pub(super) fn Demo() -> Element { "Tab 2 Content" } } - TabContent { class: "tabs-content", value: "tab3".to_string(), + TabContent { + index: 2usize, + class: "tabs-content", + value: "tab3".to_string(), div { width: "100%", height: "5rem", diff --git a/preview/src/main.rs b/preview/src/main.rs index c67dd655..d34b47c1 100644 --- a/preview/src/main.rs +++ b/preview/src/main.rs @@ -202,6 +202,7 @@ fn ComponentCode(rs_highlighted: HighlightedCode, css_highlighted: HighlightedCo justify_content: "center", align_items: "center", TabContent { + index: 0usize, class: "tabs-content", value: "main.rs", width: "100%", @@ -210,6 +211,7 @@ fn ComponentCode(rs_highlighted: HighlightedCode, css_highlighted: HighlightedCo {expand.clone()} } TabContent { + index: 1usize, class: "tabs-content", value: "style.css", width: "100%", @@ -276,27 +278,30 @@ fn Home() -> Element { let mut search = use_signal(String::new); rsx! { - div { id: "hero", - h1 { "Dioxus Primitives" } - h2 { - b { "Accessible" } - ", " - i { "unstyled" } - " foundational components for Dioxus." - } - div { id: "hero-search-container", - input { - id: "hero-search-input", - r#type: "search", - placeholder: "Search components...", - value: search, - oninput: move |e| { - search.set(e.value()); - }, + main { + role: "main", + div { id: "hero", + h1 { "Dioxus Primitives" } + h2 { + b { "Accessible" } + ", " + i { "unstyled" } + " foundational components for Dioxus." + } + div { id: "hero-search-container", + input { + id: "hero-search-input", + r#type: "search", + placeholder: "Search components...", + value: search, + oninput: move |e| { + search.set(e.value()); + }, + } } } + ComponentGallery { search } } - ComponentGallery { search } } } @@ -314,6 +319,7 @@ fn ComponentGallery(search: String) -> Element { margin: "0.5rem", top: "0", right: "0", + aria_label: "{name} details", to: Route::ComponentDemo { component_name: name.to_string(), }, diff --git a/primitives/src/calendar.rs b/primitives/src/calendar.rs index 4314da4f..42955072 100644 --- a/primitives/src/calendar.rs +++ b/primitives/src/calendar.rs @@ -159,6 +159,41 @@ impl CalendarDate { pub fn day_of_the_week(&self) -> u32 { day_of_the_week(self.year, self.month, self.day) } + + /// Get a human-readable ARIA label for this date + pub fn aria_label(&self) -> String { + let month_names = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + let day_names = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ]; + let day_of_week = self.day_of_the_week(); + format!( + "{}, {} {}, {}", + day_names[day_of_week as usize], + month_names[(self.month - 1) as usize], + self.day, + self.year + ) + } } #[test] @@ -676,7 +711,7 @@ pub fn CalendarGrid(props: CalendarGridProps) -> Element { ..props.attributes, // Day headers - thead { role: "row", + thead { aria_hidden: "true", tr { class: "calendar-grid-header", // Day name headers @@ -694,6 +729,7 @@ pub fn CalendarGrid(props: CalendarGridProps) -> Element { // Display all days in a grid for row in &*days_grid.read() { tr { + role: "row", class: "calendar-grid-week", for date in row.iter().copied() { td { @@ -773,6 +809,7 @@ fn CalendarDay(props: CalendarDayProps) -> Element { class: "calendar-grid-cell", r#type: "button", tabindex: (!in_current_month).then_some("-1"), + aria_label: props.date.aria_label(), "data-today": is_today, "data-selected": is_selected(), "data-month": "{month}", @@ -788,63 +825,3 @@ fn CalendarDay(props: CalendarDayProps) -> Element { } } } - -// Calendar Cell component props -#[derive(Props, Clone, PartialEq)] -pub struct CalendarCellProps { - /// The date for this cell - date: CalendarDate, - - /// Whether this date is selected - #[props(default)] - is_selected: bool, - - /// Whether this date is today - #[props(default)] - is_today: bool, - - /// Whether this date is disabled - #[props(default)] - is_disabled: bool, - - /// Click handler - #[props(default)] - onclick: EventHandler, - - #[props(extends = GlobalAttributes)] - attributes: Vec, -} - -// Calendar Cell component -#[component] -pub fn CalendarCell(props: CalendarCellProps) -> Element { - let _ctx: CalendarContext = use_context(); - - // Determine cell state classes - let state_class = if props.is_selected { - "calendar-grid-cell-selected" - } else if props.is_today { - "calendar-grid-cell-today" - } else { - "" - }; - - rsx! { - button { - role: "gridcell", - class: "calendar-grid-cell {state_class}", - "aria-selected": props.is_selected, - "aria-disabled": props.is_disabled, - r#type: "button", - disabled: props.is_disabled, - "data-selected": props.is_selected, - "data-today": props.is_today, - "data-disabled": props.is_disabled, - tabindex: if props.is_selected { "0" } else { "-1" }, - onclick: props.onclick, - ..props.attributes, - - {props.date.day.to_string()} - } - } -} diff --git a/primitives/src/context_menu.rs b/primitives/src/context_menu.rs index c9e6e5b6..f13f7dcf 100644 --- a/primitives/src/context_menu.rs +++ b/primitives/src/context_menu.rs @@ -145,6 +145,7 @@ pub fn ContextMenuTrigger(props: ContextMenuTriggerProps) -> Element { rsx! { div { oncontextmenu: handle_context_menu, + role: "button", aria_haspopup: "menu", aria_expanded: (ctx.open)(), ..props.attributes, diff --git a/primitives/src/dropdown_menu.rs b/primitives/src/dropdown_menu.rs index 442d2ba8..60ed5ae7 100644 --- a/primitives/src/dropdown_menu.rs +++ b/primitives/src/dropdown_menu.rs @@ -1,4 +1,4 @@ -use crate::{use_controlled, use_effect_cleanup}; +use crate::{use_controlled, use_effect_cleanup, use_unique_id}; use dioxus_lib::prelude::*; #[derive(Clone, Copy)] @@ -12,6 +12,9 @@ struct DropdownMenuContext { item_count: Signal, recent_focus: Signal, current_focus: Signal>, + + // Unique ID for the trigger button + trigger_id: Signal, } impl DropdownMenuContext { @@ -76,6 +79,7 @@ pub fn DropdownMenu(props: DropdownMenuProps) -> Element { let (open, set_open) = use_controlled(props.open, props.default_open, props.on_open_change); let disabled = props.disabled; + let trigger_id = use_unique_id(); let mut ctx = use_context_provider(|| DropdownMenuContext { open: open.into(), set_open, @@ -83,6 +87,7 @@ pub fn DropdownMenu(props: DropdownMenuProps) -> Element { item_count: Signal::new(0), recent_focus: Signal::new(0), current_focus: Signal::new(None), + trigger_id, }); // Handle escape key to close the menu @@ -113,7 +118,6 @@ pub fn DropdownMenu(props: DropdownMenuProps) -> Element { rsx! { div { - role: "menu", "data-state": if open() { "open" } else { "closed" }, "data-disabled": (props.disabled)(), onkeydown: handle_keydown, @@ -136,12 +140,13 @@ pub fn DropdownMenuTrigger(props: DropdownMenuTriggerProps) -> Element { rsx! { button { + id: "{ctx.trigger_id}", r#type: "button", "data-state": if (ctx.open)() { "open" } else { "closed" }, "data-disabled": (ctx.disabled)(), disabled: (ctx.disabled)(), aria_expanded: ctx.open, - aria_haspopup: "menu", + aria_haspopup: "listbox", onclick: move |_| { let new_open = !(ctx.open)(); @@ -172,7 +177,8 @@ pub fn DropdownMenuContent(props: DropdownMenuContentProps) -> Element { rsx! { div { - role: "menu", + role: "listbox", + aria_labelledby: "{ctx.trigger_id}", "data-state": if (ctx.open)() { "open" } else { "closed" }, ..props.attributes, {props.children} @@ -227,7 +233,7 @@ pub fn DropdownMenuItem(props: DropdownMenuItemProps) -> Element { rsx! { div { - role: "menuitem", + role: "option", "data-disabled": disabled(), tabindex: if focused() { "0" } else { "-1" }, diff --git a/primitives/src/hover_card.rs b/primitives/src/hover_card.rs index ce75d84c..9b1d9cb8 100644 --- a/primitives/src/hover_card.rs +++ b/primitives/src/hover_card.rs @@ -83,13 +83,13 @@ pub fn HoverCardTrigger(props: HoverCardTriggerProps) -> Element { let id = use_id_or(trigger_id, props.id); // Handle mouse events - let handle_mouse_enter = move |_: Event| { + let open_event = move || { if !(ctx.disabled)() { ctx.set_open.call(true); } }; - let handle_mouse_leave = move |_: Event| { + let close_event = move || { if !(ctx.disabled)() { ctx.set_open.call(false); } @@ -99,15 +99,19 @@ pub fn HoverCardTrigger(props: HoverCardTriggerProps) -> Element { div { id, class: "hover-card-trigger", + tabindex: "0", // Make the trigger focusable // Mouse events - onmouseenter: handle_mouse_enter, - onmouseleave: handle_mouse_leave, + onmouseenter: move |_| open_event(), + onmouseleave: move |_| close_event(), + + // Focus events + onfocus: move |_| open_event(), + onblur: move |_| close_event(), // ARIA attributes - aria_haspopup: "dialog", - aria_expanded: (ctx.open)(), - aria_controls: ctx.content_id.peek().clone(), + role: "button", + aria_describedby: (ctx.open)().then(|| ctx.content_id.cloned()), ..props.attributes, {props.children} diff --git a/primitives/src/select.rs b/primitives/src/select.rs index a00a0b07..11e38397 100644 --- a/primitives/src/select.rs +++ b/primitives/src/select.rs @@ -95,10 +95,8 @@ pub fn Select(props: SelectProps) -> Element { onchange: handle_change, // ARIA attributes - role: "combobox", aria_haspopup: "listbox", aria_expanded: "false", // Native select handles expansion state - aria_autocomplete: "none", aria_required: (props.required)().to_string(), aria_label: props.aria_label.clone(), aria_labelledby: props.aria_labelledby.clone(), @@ -115,7 +113,6 @@ pub fn Select(props: SelectProps) -> Element { value: "", selected: true, disabled: true, - role: "option", aria_selected: "false", {props.placeholder} } @@ -169,7 +166,6 @@ pub fn SelectOption(props: SelectOptionProps) -> Element { disabled: (props.disabled)(), // ARIA attributes - role: "option", aria_selected: "false", // Will be set to true by the browser when selected aria_disabled: (props.disabled)().to_string(), aria_label: props.aria_label.clone(), diff --git a/primitives/src/slider.rs b/primitives/src/slider.rs index cc457d6d..b6738e73 100644 --- a/primitives/src/slider.rs +++ b/primitives/src/slider.rs @@ -128,6 +128,9 @@ pub struct SliderProps { #[props(default)] on_value_change: Callback, + /// The label for the slider (for accessibility) + label: ReadOnlySignal>, + #[props(extends = GlobalAttributes)] attributes: Vec, @@ -160,6 +163,7 @@ pub fn Slider(props: SliderProps) -> Element { horizontal: props.horizontal, inverted: props.inverted, dragging: dragging.into(), + label: props.label, }); let mut rect = use_signal(|| None); @@ -422,6 +426,8 @@ pub fn SliderThumb(props: SliderThumbProps) -> Element { } }); + let aria_label = ctx.label; + rsx! { button { r#type: "button", @@ -430,6 +436,7 @@ pub fn SliderThumb(props: SliderThumbProps) -> Element { aria_valuemax: ctx.max, aria_valuenow: value, aria_orientation: orientation, + aria_label, "data-disabled": ctx.disabled, "data-orientation": orientation, "data-dragging": ctx.dragging, @@ -495,6 +502,7 @@ struct SliderContext { horizontal: bool, inverted: bool, dragging: ReadOnlySignal, + label: ReadOnlySignal>, } impl SliderContext { diff --git a/primitives/src/tabs.rs b/primitives/src/tabs.rs index 303d3363..d7f01e5f 100644 --- a/primitives/src/tabs.rs +++ b/primitives/src/tabs.rs @@ -1,4 +1,4 @@ -use crate::{use_controlled, use_effect_cleanup}; +use crate::{use_controlled, use_effect_cleanup, use_id_or, use_unique_id}; use dioxus_lib::prelude::*; use std::rc::Rc; @@ -18,6 +18,9 @@ struct TabsContext { horizontal: ReadOnlySignal, roving_focus: ReadOnlySignal, roving_loop: ReadOnlySignal, + + // ARIA attributes + tab_content_ids: Signal>, } impl TabsContext { @@ -110,11 +113,11 @@ pub fn Tabs(props: TabsProps) -> Element { horizontal: props.horizontal, roving_focus: props.roving_focus, roving_loop: props.roving_loop, + tab_content_ids: Signal::new(Vec::new()), }); rsx! { div { - role: "tablist", "data-orientation": if (props.horizontal)() { "horizontal" } else { "vertical" }, "data-disabled": (props.disabled)(), @@ -126,6 +129,26 @@ pub fn Tabs(props: TabsProps) -> Element { } } +#[derive(Props, Clone, PartialEq)] +pub struct TabListProps { + #[props(extends = GlobalAttributes)] + attributes: Vec, + + children: Element, +} + +#[component] +pub fn TabList(props: TabListProps) -> Element { + rsx! { + div { + role: "tablist", + ..props.attributes, + + {props.children} + } + } +} + #[derive(Props, Clone, PartialEq)] pub struct TabTriggerProps { value: String, @@ -199,6 +222,7 @@ pub fn TabTrigger(props: TabTriggerProps) -> Element { tabindex: tab_index, aria_selected: selected, + aria_controls: (ctx.tab_content_ids)().get((props.index)()).cloned(), "data-state": if selected() { "active" } else { "inactive" }, "data-disabled": (ctx.disabled)() || (props.disabled)(), disabled: (ctx.disabled)() || (props.disabled)(), @@ -244,8 +268,11 @@ pub fn TabTrigger(props: TabTriggerProps) -> Element { pub struct TabContentProps { value: String, - id: Option, + id: ReadOnlySignal>, class: Option, + + index: ReadOnlySignal, + #[props(extends = GlobalAttributes)] #[props(extends = div)] attributes: Vec, @@ -255,13 +282,24 @@ pub struct TabContentProps { #[component] pub fn TabContent(props: TabContentProps) -> Element { - let ctx: TabsContext = use_context(); + let mut ctx: TabsContext = use_context(); let selected = use_memo(move || (ctx.value)() == props.value); + let uuid = use_unique_id(); + let id = use_id_or(uuid, props.id); + + use_effect(move || { + let mut tab_ids = ctx.tab_content_ids.write(); + let index = (props.index)(); + while tab_ids.len() <= index { + tab_ids.push(String::new()); + } + tab_ids[index] = id(); + }); rsx! { div { role: "tabpanel", - id: props.id, + id, class: props.class, tabindex: "0",