diff --git a/packages/pluggableWidgets/skiplink-web/.gitignore b/packages/pluggableWidgets/skiplink-web/.gitignore new file mode 100644 index 0000000000..a1bd0102fd --- /dev/null +++ b/packages/pluggableWidgets/skiplink-web/.gitignore @@ -0,0 +1,14 @@ +/tests/TestProjects/**/.classpath +/tests/TestProjects/**/.project +/tests/TestProjects/**/javascriptsource +/tests/TestProjects/**/javasource +/tests/TestProjects/**/resources +/tests/TestProjects/**/userlib + +/tests/TestProjects/Mendix8/theme/styles/native +/tests/TestProjects/Mendix8/theme/styles/web/sass +/tests/TestProjects/Mendix8/theme/*.* +!/tests/TestProjects/Mendix8/theme/components.json +!/tests/TestProjects/Mendix8/theme/favicon.ico +!/tests/TestProjects/Mendix8/theme/LICENSE +!/tests/TestProjects/Mendix8/theme/settings.json diff --git a/packages/pluggableWidgets/skiplink-web/.prettierrc.js b/packages/pluggableWidgets/skiplink-web/.prettierrc.js new file mode 100644 index 0000000000..0892704ab0 --- /dev/null +++ b/packages/pluggableWidgets/skiplink-web/.prettierrc.js @@ -0,0 +1 @@ +module.exports = require("@mendix/prettier-config-web-widgets"); diff --git a/packages/pluggableWidgets/skiplink-web/README.md b/packages/pluggableWidgets/skiplink-web/README.md new file mode 100644 index 0000000000..02b3c70903 --- /dev/null +++ b/packages/pluggableWidgets/skiplink-web/README.md @@ -0,0 +1,27 @@ +# SkipLink Web Widget + +A simple accessibility widget that adds a skip link to the top of the page. The link is only visible when focused and allows users to jump directly to the main content. + +## Usage + +1. Place the `` component at the very top of your page or layout. +2. Ensure your main content container has `id="main-content"`. + + ```jsx + +
Main content here
+ ``` + +## Accessibility + +- The skip link is visually hidden except when focused, making it accessible for keyboard and screen reader users. + +## End-to-End Testing + +E2E tests are located in the `e2e/` folder and use Playwright. Run them with: + +``` +npm install +npx playwright install +npm test +``` diff --git a/packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.ts b/packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.ts new file mode 100644 index 0000000000..80c13e8155 --- /dev/null +++ b/packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from "@playwright/test"; + +// Assumes the test project renders and a
element + +test.describe("SkipLink", () => { + test("should be hidden by default and visible on focus, and should skip to main content", async ({ page }) => { + await page.goto("/"); + const skipLink = page.locator(".skip-link"); + // Should be hidden by default + await expect(skipLink).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, -120)"); + // Tab to focus the skip link + await page.keyboard.press("Tab"); + await expect(skipLink).toBeVisible(); + // Check if skipLink is the active element + const isFocused = await skipLink.evaluate(node => node === document.activeElement); + expect(isFocused).toBe(true); + // Press Enter to activate the link + await page.keyboard.press("Enter"); + // The main content should be focused or scrolled into view + const main = page.locator("#main-content"); + await expect(main).toBeVisible(); + }); +}); diff --git a/packages/pluggableWidgets/skiplink-web/e2e/package.json b/packages/pluggableWidgets/skiplink-web/e2e/package.json new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/pluggableWidgets/skiplink-web/eslint.config.mjs b/packages/pluggableWidgets/skiplink-web/eslint.config.mjs new file mode 100644 index 0000000000..ed68ae9e78 --- /dev/null +++ b/packages/pluggableWidgets/skiplink-web/eslint.config.mjs @@ -0,0 +1,3 @@ +import config from "@mendix/eslint-config-web-widgets/widget-ts.mjs"; + +export default config; diff --git a/packages/pluggableWidgets/skiplink-web/jest.config.js b/packages/pluggableWidgets/skiplink-web/jest.config.js new file mode 100644 index 0000000000..88999d5568 --- /dev/null +++ b/packages/pluggableWidgets/skiplink-web/jest.config.js @@ -0,0 +1,3 @@ +module.exports = { + ...require("@mendix/pluggable-widgets-tools/test-config/jest.enzyme-free.config.js") +}; diff --git a/packages/pluggableWidgets/skiplink-web/package.json b/packages/pluggableWidgets/skiplink-web/package.json new file mode 100644 index 0000000000..dd660ba034 --- /dev/null +++ b/packages/pluggableWidgets/skiplink-web/package.json @@ -0,0 +1,55 @@ +{ + "name": "@mendix/skiplink-web", + "widgetName": "SkipLink", + "version": "1.0.0", + "description": "Adds a skip link to the top of the page for accessibility.", + "copyright": "© Mendix Technology BV 2025. All rights reserved.", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/mendix/web-widgets.git" + }, + "config": {}, + "mxpackage": { + "name": "SkipLink", + "type": "widget", + "mpkName": "com.mendix.widget.web.SkipLink.mpk" + }, + "packagePath": "com.mendix.widget.web", + "marketplace": { + "minimumMXVersion": "11.1.0", + "appNumber": 119999, + "appName": "SkipLink", + "reactReady": true + }, + "scripts": { + "build": "pluggable-widgets-tools build:web", + "create-gh-release": "rui-create-gh-release", + "create-translation": "rui-create-translation", + "dev": "pluggable-widgets-tools start:web", + "e2e": "run-e2e ci", + "e2edev": "run-e2e dev --with-preps", + "format": "prettier --ignore-path ./node_modules/@mendix/prettier-config-web-widgets/global-prettierignore --write .", + "lint": "eslint src/ package.json", + "publish-marketplace": "rui-publish-marketplace", + "release": "pluggable-widgets-tools release:web", + "start": "pluggable-widgets-tools start:server", + "test": "jest --projects jest.config.js", + "update-changelog": "rui-update-changelog-widget", + "verify": "rui-verify-package-format" + }, + "dependencies": { + "@floating-ui/react": "^0.26.27", + "@mendix/widget-plugin-component-kit": "workspace:*", + "classnames": "^2.5.1" + }, + "devDependencies": { + "@mendix/automation-utils": "workspace:*", + "@mendix/eslint-config-web-widgets": "workspace:*", + "@mendix/pluggable-widgets-tools": "*", + "@mendix/prettier-config-web-widgets": "workspace:*", + "@mendix/run-e2e": "workspace:*", + "@mendix/widget-plugin-hooks": "workspace:*", + "@mendix/widget-plugin-platform": "workspace:*" + } +} diff --git a/packages/pluggableWidgets/skiplink-web/playwright.config.cjs b/packages/pluggableWidgets/skiplink-web/playwright.config.cjs new file mode 100644 index 0000000000..29045fc372 --- /dev/null +++ b/packages/pluggableWidgets/skiplink-web/playwright.config.cjs @@ -0,0 +1 @@ +module.exports = require("@mendix/run-e2e/playwright.config.cjs"); diff --git a/packages/pluggableWidgets/skiplink-web/src/SkipLink.editorConfig.ts b/packages/pluggableWidgets/skiplink-web/src/SkipLink.editorConfig.ts new file mode 100644 index 0000000000..6fa0fe3dbb --- /dev/null +++ b/packages/pluggableWidgets/skiplink-web/src/SkipLink.editorConfig.ts @@ -0,0 +1,80 @@ +import { Problem, Properties } from "@mendix/pluggable-widgets-tools"; +import { + StructurePreviewProps, + RowLayoutProps, + ContainerProps, + TextProps, + structurePreviewPalette +} from "@mendix/widget-plugin-platform/preview/structure-preview-api"; + +export function getProperties(defaultValues: Properties): Properties { + // No conditional properties for skiplink, but function provided for consistency + return defaultValues; +} + +export function check(values: any): Problem[] { + const errors: Problem[] = []; + if (!values.linkText) { + errors.push({ + property: "linkText", + message: "Link text is required" + }); + } + if (!values.mainContentId) { + errors.push({ + property: "mainContentId", + message: "Main content ID is required" + }); + } + return errors; +} + +export function getPreview(values: any, isDarkMode: boolean): StructurePreviewProps | null { + const palette = structurePreviewPalette[isDarkMode ? "dark" : "light"]; + const titleHeader: RowLayoutProps = { + type: "RowLayout", + columnSize: "grow", + backgroundColor: palette.background.topbarStandard, + borders: true, + borderWidth: 1, + children: [ + { + type: "Container", + padding: 4, + children: [ + { + type: "Text", + content: "SkipLink", + fontColor: palette.text.secondary + } as TextProps + ] + } + ] + }; + const linkContent: RowLayoutProps = { + type: "RowLayout", + columnSize: "grow", + borders: true, + padding: 0, + children: [ + { + type: "Container", + padding: 6, + children: [ + { + type: "Text", + content: values.linkText || "Skip to main content", + fontSize: 14, + fontColor: palette.text.primary, + bold: true + } as TextProps + ] + } + ] + }; + return { + type: "Container", + borders: true, + children: [titleHeader, linkContent] + } as ContainerProps; +} diff --git a/packages/pluggableWidgets/skiplink-web/src/SkipLink.editorPreview.tsx b/packages/pluggableWidgets/skiplink-web/src/SkipLink.editorPreview.tsx new file mode 100644 index 0000000000..543e043658 --- /dev/null +++ b/packages/pluggableWidgets/skiplink-web/src/SkipLink.editorPreview.tsx @@ -0,0 +1,34 @@ +import { createElement, ReactElement } from "react"; +import { SkipLinkPreviewProps } from "../typings/SkipLinkProps"; + +export const preview = (props: SkipLinkPreviewProps): ReactElement => { + if (props.renderMode === "xray") { + return ( +
+ + {props.linkText} + +
+ ); + } + return
; +}; + +export function getPreviewCss(): string { + return require("./ui/SkipLink.scss"); +} diff --git a/packages/pluggableWidgets/skiplink-web/src/SkipLink.tsx b/packages/pluggableWidgets/skiplink-web/src/SkipLink.tsx new file mode 100644 index 0000000000..2c748c0263 --- /dev/null +++ b/packages/pluggableWidgets/skiplink-web/src/SkipLink.tsx @@ -0,0 +1,66 @@ +import "./ui/SkipLink.scss"; +import { useEffect } from "react"; + +export interface SkipLinkProps { + /** + * The text displayed for the skip link. + */ + linkText: string; + /** + * The id of the main content element to jump to. + */ + mainContentId: string; +} + +/** + * Inserts a skip link as the first child of the element with ID 'root'. + * When activated, focus is programmatically set to the main content. + */ +export function SkipLink({ linkText, mainContentId }: SkipLinkProps): null { + useEffect(() => { + // Create the skip link element + const link = document.createElement("a"); + link.href = `#${mainContentId}`; + link.className = "skip-link"; + link.textContent = linkText; + link.tabIndex = 0; + + // Handler to move focus to the main content + function handleClick(event: MouseEvent) { + event.preventDefault(); + const main = document.getElementById(mainContentId); + if (main) { + // Store previous tabindex + const prevTabIndex = main.getAttribute("tabindex"); + // Ensure main is focusable + if (!main.hasAttribute("tabindex")) { + main.setAttribute("tabindex", "-1"); + } + main.focus(); + // Clean up tabindex if it was not present before + if (prevTabIndex === null) { + main.addEventListener("blur", () => main.removeAttribute("tabindex"), { once: true }); + } + } + } + + link.addEventListener("click", handleClick); + + // Insert as the first child of the element with ID 'root' + const root = document.getElementById("root"); + if (root) { + root.insertBefore(link, root.firstChild); + } + + // Cleanup on unmount + return () => { + link.removeEventListener("click", handleClick); + if (link.parentNode) { + link.parentNode.removeChild(link); + } + }; + }, [linkText, mainContentId]); + + // This component does not render anything in the React tree + return null; +} diff --git a/packages/pluggableWidgets/skiplink-web/src/SkipLink.xml b/packages/pluggableWidgets/skiplink-web/src/SkipLink.xml new file mode 100644 index 0000000000..9769fe7d01 --- /dev/null +++ b/packages/pluggableWidgets/skiplink-web/src/SkipLink.xml @@ -0,0 +1,20 @@ + + + SkipLink + A skip link for accessibility, allowing users to jump directly to the main content. + Accessibility + Accessibility + https://docs.mendix.com/appstore/widgets/skiplink + + + + Link text + The text displayed for the skip link. + + + Main content ID + The id of the main content element to jump to. + + + + diff --git a/packages/pluggableWidgets/skiplink-web/src/package.xml b/packages/pluggableWidgets/skiplink-web/src/package.xml new file mode 100644 index 0000000000..811a87e4ab --- /dev/null +++ b/packages/pluggableWidgets/skiplink-web/src/package.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/pluggableWidgets/skiplink-web/src/ui/SkipLink.scss b/packages/pluggableWidgets/skiplink-web/src/ui/SkipLink.scss new file mode 100644 index 0000000000..5a613ceb39 --- /dev/null +++ b/packages/pluggableWidgets/skiplink-web/src/ui/SkipLink.scss @@ -0,0 +1,20 @@ +.skip-link { + position: absolute; + top: 0; + left: 0; + background: #fff; + color: #0078d4; + padding: 8px 16px; + z-index: 1000; + transform: translateY(-120%); + transition: transform 0.2s; + text-decoration: none; + border: 2px solid #0078d4; + border-radius: 4px; + font-weight: bold; +} + +.skip-link:focus { + transform: translateY(0); + outline: none; +} diff --git a/packages/pluggableWidgets/skiplink-web/tsconfig.json b/packages/pluggableWidgets/skiplink-web/tsconfig.json new file mode 100644 index 0000000000..a2a5b87e60 --- /dev/null +++ b/packages/pluggableWidgets/skiplink-web/tsconfig.json @@ -0,0 +1,31 @@ +{ + "include": ["./src", "./typings"], + "compilerOptions": { + "baseUrl": "./", + "noEmitOnError": true, + "sourceMap": true, + "module": "esnext", + "target": "es6", + "lib": ["esnext", "dom"], + "types": ["jest", "node"], + "moduleResolution": "node", + "declaration": false, + "noLib": false, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "strict": true, + "strictFunctionTypes": false, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "jsx": "react", + "jsxFactory": "createElement", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "useUnknownInCatchVariables": false, + "exactOptionalPropertyTypes": false, + "paths": { + "react-hot-loader/root": ["./hot-typescript.ts"] + } + } +} diff --git a/packages/pluggableWidgets/skiplink-web/typings/SkipLinkProps.d.ts b/packages/pluggableWidgets/skiplink-web/typings/SkipLinkProps.d.ts new file mode 100644 index 0000000000..dd4d4c8a82 --- /dev/null +++ b/packages/pluggableWidgets/skiplink-web/typings/SkipLinkProps.d.ts @@ -0,0 +1,30 @@ +/** + * This file was generated from SkipLink.xml + * WARNING: All changes made to this file will be overwritten + * @author Mendix Widgets Framework Team + */ +import { CSSProperties } from "react"; + +export interface SkipLinkContainerProps { + name: string; + class: string; + style?: CSSProperties; + tabIndex?: number; + linkText: string; + mainContentId: string; +} + +export interface SkipLinkPreviewProps { + /** + * @deprecated Deprecated since version 9.18.0. Please use class property instead. + */ + className: string; + class: string; + style: string; + styleObject?: CSSProperties; + readOnly: boolean; + renderMode: "design" | "xray" | "structure"; + translate: (text: string) => string; + linkText: string; + mainContentId: string; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40d480cc17..4f024987f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2138,6 +2138,40 @@ importers: specifier: ^7.0.3 version: 7.0.3 + packages/pluggableWidgets/skiplink-web: + dependencies: + '@floating-ui/react': + specifier: ^0.26.27 + version: 0.26.27(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@mendix/widget-plugin-component-kit': + specifier: workspace:* + version: link:../../shared/widget-plugin-component-kit + classnames: + specifier: ^2.5.1 + version: 2.5.1 + devDependencies: + '@mendix/automation-utils': + specifier: workspace:* + version: link:../../../automation/utils + '@mendix/eslint-config-web-widgets': + specifier: workspace:* + version: link:../../shared/eslint-config-web-widgets + '@mendix/pluggable-widgets-tools': + specifier: 10.21.2 + version: 10.21.2(@jest/transform@29.7.0)(@jest/types@29.6.3)(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/babel__core@7.20.3)(@types/node@22.14.1)(picomatch@4.0.2)(react-dom@18.2.0(react@18.2.0))(react-native@0.75.3(@babel/core@7.27.4)(@babel/preset-env@7.26.9(@babel/core@7.27.4))(@types/react@18.2.36)(react@18.2.0))(react@18.2.0)(tslib@2.8.1) + '@mendix/prettier-config-web-widgets': + specifier: workspace:* + version: link:../../shared/prettier-config-web-widgets + '@mendix/run-e2e': + specifier: workspace:* + version: link:../../../automation/run-e2e + '@mendix/widget-plugin-hooks': + specifier: workspace:* + version: link:../../shared/widget-plugin-hooks + '@mendix/widget-plugin-platform': + specifier: workspace:* + version: link:../../shared/widget-plugin-platform + packages/pluggableWidgets/slider-web: dependencies: '@mendix/widget-plugin-component-kit': @@ -17602,7 +17636,7 @@ snapshots: dependencies: array-union: 2.1.0 dir-glob: 3.0.1 - fast-glob: 3.3.2 + fast-glob: 3.3.3 ignore: 5.3.2 merge2: 1.4.1 slash: 3.0.0