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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/pluggableWidgets/skiplink-web/.gitignore
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions packages/pluggableWidgets/skiplink-web/.prettierrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require("@mendix/prettier-config-web-widgets");
27 changes: 27 additions & 0 deletions packages/pluggableWidgets/skiplink-web/README.md
Original file line number Diff line number Diff line change
@@ -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 `<SkipLink />` component at the very top of your page or layout.
2. Ensure your main content container has `id="main-content"`.

```jsx
<SkipLink />
<main id="main-content">Main content here</main>
```

## 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
```
3 changes: 3 additions & 0 deletions packages/pluggableWidgets/skiplink-web/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import config from "@mendix/eslint-config-web-widgets/widget-ts.mjs";

export default config;
3 changes: 3 additions & 0 deletions packages/pluggableWidgets/skiplink-web/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
...require("@mendix/pluggable-widgets-tools/test-config/jest.enzyme-free.config.js")
};
56 changes: 56 additions & 0 deletions packages/pluggableWidgets/skiplink-web/package.json
Original file line number Diff line number Diff line change
@@ -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:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require("@mendix/run-e2e/playwright.config.cjs");
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Problem, Properties } from "@mendix/pluggable-widgets-tools";
import {
StructurePreviewProps,
RowLayoutProps,

Check failure on line 4 in packages/pluggableWidgets/skiplink-web/src/SkipLink.editorConfig.ts

View workflow job for this annotation

GitHub Actions / Run code quality check

Member 'RowLayoutProps' of the import declaration should be sorted alphabetically
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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { ReactElement } from "react";
import { SkipLinkPreviewProps } from "../typings/SkipLinkProps";

export const preview = (props: SkipLinkPreviewProps): ReactElement => {
if (props.renderMode === "xray") {
return (
<div style={{ position: "relative", height: 40 }}>
<a
href={`#${props.mainContentId}`}
style={{
position: "absolute",
top: 0,
left: 0,
background: "#fff",
color: "#0078d4",
padding: "8px 16px",
zIndex: 1000,
textDecoration: "none",
border: "2px solid #0078d4",
borderRadius: 4,
fontWeight: "bold"
}}
>
{props.linkText}
</a>
</div>
);
} else {
return <a

Check failure on line 29 in packages/pluggableWidgets/skiplink-web/src/SkipLink.editorPreview.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

Insert `(⏎············`
href={`#${props.mainContentId}`}

Check failure on line 30 in packages/pluggableWidgets/skiplink-web/src/SkipLink.editorPreview.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

Replace `········` with `················`
style={{

Check failure on line 31 in packages/pluggableWidgets/skiplink-web/src/SkipLink.editorPreview.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

Insert `········`
position: "absolute",

Check failure on line 32 in packages/pluggableWidgets/skiplink-web/src/SkipLink.editorPreview.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

Replace `············` with `····················`
top: 0,

Check failure on line 33 in packages/pluggableWidgets/skiplink-web/src/SkipLink.editorPreview.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

Replace `············` with `····················`
left: 0,

Check failure on line 34 in packages/pluggableWidgets/skiplink-web/src/SkipLink.editorPreview.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

Replace `············` with `····················`
background: "#fff",

Check failure on line 35 in packages/pluggableWidgets/skiplink-web/src/SkipLink.editorPreview.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

Replace `············` with `····················`
color: "#0078d4",

Check failure on line 36 in packages/pluggableWidgets/skiplink-web/src/SkipLink.editorPreview.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

Insert `········`
padding: "8px 16px",

Check failure on line 37 in packages/pluggableWidgets/skiplink-web/src/SkipLink.editorPreview.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

Replace `············` with `····················`
zIndex: 1000,
textDecoration: "none",
border: "2px solid #0078d4",
borderRadius: 4,
fontWeight: "bold"
}}
>
{props.linkText}
</a>
}
};

export function getPreviewCss(): string {
return require("./ui/SkipLink.scss");
}
74 changes: 74 additions & 0 deletions packages/pluggableWidgets/skiplink-web/src/SkipLink.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
20 changes: 20 additions & 0 deletions packages/pluggableWidgets/skiplink-web/src/SkipLink.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8" ?>
<widget id="com.mendix.widget.web.skiplink.SkipLink" pluginWidget="true" offlineCapable="true" xmlns="http://www.mendix.com/widget/1.0/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.mendix.com/widget/1.0/ ../../../../node_modules/mendix/custom_widget.xsd">
<name>SkipLink</name>
<description>A skip link for accessibility, allowing users to jump directly to the main content.</description>
<studioProCategory>Accessibility</studioProCategory>
<studioCategory>Accessibility</studioCategory>
<helpUrl></helpUrl>
<properties>
<propertyGroup caption="General">
<property key="linkText" type="string" defaultValue="Skip to main content">
<caption>Link text</caption>
<description>The text displayed in the skip link.</description>
</property>
<property key="mainContentId" type="string">
<caption>Main content ID</caption>
<description>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.</description>
</property>
</propertyGroup>
</properties>
</widget>
Loading
Loading