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/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..8b21f5ad6e --- /dev/null +++ b/packages/pluggableWidgets/skiplink-web/package.json @@ -0,0 +1,56 @@ +{ + "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:*", + "@mendix/widget-plugin-test-utils": "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..212808ef3a --- /dev/null +++ b/packages/pluggableWidgets/skiplink-web/src/SkipLink.editorConfig.ts @@ -0,0 +1,74 @@ +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" + }); + } + 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..7d1c2809ab --- /dev/null +++ b/packages/pluggableWidgets/skiplink-web/src/SkipLink.editorPreview.tsx @@ -0,0 +1,52 @@ +import { ReactElement } from "react"; +import { SkipLinkPreviewProps } from "../typings/SkipLinkProps"; + +export const preview = (props: SkipLinkPreviewProps): ReactElement => { + if (props.renderMode === "xray") { + return ( +
+ + {props.linkText} + +
+ ); + } else { + return + {props.linkText} + + } +}; + +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..e6f6e17398 --- /dev/null +++ b/packages/pluggableWidgets/skiplink-web/src/SkipLink.tsx @@ -0,0 +1,74 @@ +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(); + let main: HTMLElement; + const mainByID = document.getElementById(mainContentId); + if(mainContentId !== "" && mainByID !== null){ + main = mainByID; + } + else { + main = document.getElementsByTagName("main")[0]; + } + + 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..5761834caf --- /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 + + + + + Link text + The text displayed in the skip link. + + + Main content ID + The id of the main content element to jump to, if left empty the skip link widget will search for a main tag on the page. + + + + diff --git a/packages/pluggableWidgets/skiplink-web/src/__tests__/SkipLink.spec.tsx b/packages/pluggableWidgets/skiplink-web/src/__tests__/SkipLink.spec.tsx new file mode 100644 index 0000000000..93fe4c9075 --- /dev/null +++ b/packages/pluggableWidgets/skiplink-web/src/__tests__/SkipLink.spec.tsx @@ -0,0 +1,84 @@ +import "@testing-library/jest-dom"; +import { render } from "@testing-library/react"; +import { SkipLinkContainerProps } from "../../typings/SkipLinkProps"; +import { SkipLink } from "../SkipLink"; + +describe("SkipLink", () => { + let defaultProps: SkipLinkContainerProps; + let rootElement: HTMLElement; + + beforeEach(() => { + // Set up the DOM structure that SkipLink expects + document.body.innerHTML = ""; + rootElement = document.createElement("div"); + rootElement.id = "root"; + document.body.appendChild(rootElement); + + defaultProps = { + name: "SkipLink1", + class: "mx-skiplink", + style: {}, + linkText: "Skip to main content", + mainContentId: "main-content" + }; + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("renders skiplink widget and adds skip link to DOM", () => { + render(); + + // Check that the skip link was added to the root element + const skipLink = rootElement.querySelector(".skip-link") as HTMLAnchorElement; + expect(skipLink).toBeInTheDocument(); + expect(skipLink.textContent).toBe("Skip to main content"); + expect(skipLink.href).toBe(`${window.location.origin}/#main-content`); + expect(skipLink.tabIndex).toBe(0); + + // Snapshot the actual root element that contains the skip link + expect(rootElement).toMatchSnapshot(); + }); + + it("renders with custom link text", () => { + render(); + + const skipLink = rootElement.querySelector(".skip-link") as HTMLAnchorElement; + expect(skipLink).toBeInTheDocument(); + expect(skipLink.textContent).toBe("Jump to content"); + + expect(rootElement).toMatchSnapshot(); + }); + + it("renders with custom main content id", () => { + render(); + + const skipLink = rootElement.querySelector(".skip-link") as HTMLAnchorElement; + expect(skipLink).toBeInTheDocument(); + expect(skipLink.href).toBe(`${window.location.origin}/#content-area`); + + expect(rootElement).toMatchSnapshot(); + }); + + it("renders with empty main content id", () => { + render(); + + const skipLink = rootElement.querySelector(".skip-link") as HTMLAnchorElement; + expect(skipLink).toBeInTheDocument(); + expect(skipLink.href).toBe(`${window.location.origin}/#`); + + expect(rootElement).toMatchSnapshot(); + }); + + it("cleans up skip link when component unmounts", () => { + const { unmount } = render(); + + // Verify skip link is present + expect(rootElement.querySelector(".skip-link")).toBeInTheDocument(); + + // Unmount and verify cleanup + unmount(); + expect(rootElement.querySelector(".skip-link")).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/packages/pluggableWidgets/skiplink-web/src/__tests__/__snapshots__/SkipLink.spec.tsx.snap b/packages/pluggableWidgets/skiplink-web/src/__tests__/__snapshots__/SkipLink.spec.tsx.snap new file mode 100644 index 0000000000..1dc3dc6f8c --- /dev/null +++ b/packages/pluggableWidgets/skiplink-web/src/__tests__/__snapshots__/SkipLink.spec.tsx.snap @@ -0,0 +1,57 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SkipLink renders skiplink widget and adds skip link to DOM 1`] = ` + +`; + +exports[`SkipLink renders with custom link text 1`] = ` + +`; + +exports[`SkipLink renders with custom main content id 1`] = ` + +`; + +exports[`SkipLink renders with empty main content id 1`] = ` + +`; 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..7aa60df0c9 --- /dev/null +++ b/packages/pluggableWidgets/skiplink-web/tsconfig.json @@ -0,0 +1,30 @@ +{ + "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-jsx", + "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 a66b2b5cce..72ef7954ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2264,6 +2264,43 @@ importers: specifier: ^7.0.3 version: 7.0.3 + packages/pluggableWidgets/skiplink-web: + dependencies: + '@floating-ui/react': + specifier: ^0.26.27 + version: 0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@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@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(jest-util@30.2.0)(picomatch@4.0.3)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.28.4)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(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 + '@mendix/widget-plugin-test-utils': + specifier: workspace:* + version: link:../../shared/widget-plugin-test-utils + packages/pluggableWidgets/slider-web: dependencies: '@mendix/widget-plugin-component-kit': @@ -12477,7 +12514,7 @@ snapshots: identity-obj-proxy: 3.0.0 jasmine: 3.99.0 jasmine-core: 3.99.1 - jest: 29.7.0(@types/node@22.14.1) + jest: 29.7.0(@types/node@22.14.1)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.14.1)(typescript@5.9.3)) jest-environment-jsdom: 29.7.0 jest-jasmine2: 29.7.0 jest-junit: 13.2.0 @@ -17020,18 +17057,6 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@22.14.1): - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.14.1)(typescript@5.9.3)) - '@jest/types': 29.6.3 - import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@22.14.1)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.14.1)(typescript@5.9.3)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - jest@29.7.0(@types/node@22.14.1)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.14.1)(typescript@5.9.3)): dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.14.1)(typescript@5.9.3)) @@ -19896,7 +19921,7 @@ snapshots: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.8 - jest: 29.7.0(@types/node@22.14.1) + jest: 29.7.0(@types/node@22.14.1)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.14.1)(typescript@5.9.3)) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6