Skip to content

Commit b0791c5

Browse files
committed
feat: initial setup of skiplink widget
1 parent bd49b44 commit b0791c5

File tree

10 files changed

+298
-31
lines changed

10 files changed

+298
-31
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# SkipLink Web Widget
2+
3+
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.
4+
5+
## Usage
6+
7+
1. Place the `<SkipLink />` component at the very top of your page or layout.
8+
2. Ensure your main content container has `id="main-content"`.
9+
10+
```jsx
11+
<SkipLink />
12+
<main id="main-content">Main content here</main>
13+
```
14+
15+
## Accessibility
16+
17+
- The skip link is visually hidden except when focused, making it accessible for keyboard and screen reader users.
18+
19+
## End-to-End Testing
20+
21+
E2E tests are located in the `e2e/` folder and use Playwright. Run them with:
22+
23+
```
24+
npm install
25+
npx playwright install
26+
npm test
27+
```
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
// Assumes the test project renders <SkipLink /> and a <main id="main-content"> element
4+
5+
test.describe("SkipLink", () => {
6+
test("should be hidden by default and visible on focus, and should skip to main content", async ({ page }) => {
7+
await page.goto("/");
8+
const skipLink = page.locator(".skip-link");
9+
// Should be hidden by default
10+
await expect(skipLink).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, -120)");
11+
// Tab to focus the skip link
12+
await page.keyboard.press("Tab");
13+
await expect(skipLink).toBeVisible();
14+
// Check if skipLink is the active element
15+
const isFocused = await skipLink.evaluate(node => node === document.activeElement);
16+
expect(isFocused).toBe(true);
17+
// Press Enter to activate the link
18+
await page.keyboard.press("Enter");
19+
// The main content should be focused or scrolled into view
20+
const main = page.locator("#main-content");
21+
await expect(main).toBeVisible();
22+
});
23+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{
2+
"name": "@mendix/pluggable-widget-skiplink-web",
3+
"widgetName": "SkipLink",
4+
"version": "1.0.0",
5+
"description": "Adds a skip link to the top of the page for accessibility.",
6+
"copyright": "© Mendix Technology BV 2025. All rights reserved.",
7+
"license": "Apache-2.0",
8+
"repository": {
9+
"type": "git",
10+
"url": "https://github.com/mendix/web-widgets.git"
11+
},
12+
"config": {},
13+
"mxpackage": {
14+
"name": "SkipLink",
15+
"type": "widget",
16+
"mpkName": "com.mendix.widget.web.SkipLink.mpk"
17+
},
18+
"packagePath": "com.mendix.widget.web",
19+
"marketplace": {
20+
"minimumMXVersion": "9.6.0",
21+
"appNumber": 119999,
22+
"appName": "SkipLink",
23+
"reactReady": true
24+
},
25+
"scripts": {
26+
"build": "pluggable-widgets-tools build:web",
27+
"create-gh-release": "rui-create-gh-release",
28+
"create-translation": "rui-create-translation",
29+
"dev": "pluggable-widgets-tools start:web",
30+
"e2e": "run-e2e ci",
31+
"e2edev": "run-e2e dev --with-preps",
32+
"format": "prettier --ignore-path ./node_modules/@mendix/prettier-config-web-widgets/global-prettierignore --write .",
33+
"lint": "eslint src/ package.json",
34+
"publish-marketplace": "rui-publish-marketplace",
35+
"release": "pluggable-widgets-tools release:web",
36+
"start": "pluggable-widgets-tools start:server",
37+
"test": "jest --projects jest.config.js",
38+
"update-changelog": "rui-update-changelog-widget",
39+
"verify": "rui-verify-package-format"
40+
},
41+
"devDependencies": {
42+
"@mendix/automation-utils": "workspace:*",
43+
"@mendix/eslint-config-web-widgets": "workspace:*",
44+
"@mendix/pluggable-widgets-tools": "*",
45+
"@mendix/prettier-config-web-widgets": "workspace:*",
46+
"@mendix/run-e2e": "workspace:*",
47+
"@mendix/widget-plugin-hooks": "workspace:*",
48+
"@mendix/widget-plugin-platform": "workspace:*"
49+
}
50+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
.skip-link {
2+
position: absolute;
3+
top: 0;
4+
left: 0;
5+
background: #fff;
6+
color: #0078d4;
7+
padding: 8px 16px;
8+
z-index: 1000;
9+
transform: translateY(-120%);
10+
transition: transform 0.2s;
11+
text-decoration: none;
12+
border: 2px solid #0078d4;
13+
border-radius: 4px;
14+
font-weight: bold;
15+
}
16+
17+
.skip-link:focus {
18+
transform: translateY(0);
19+
outline: none;
20+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import "./SkipLink.css";
2+
import { useEffect } from "react";
3+
4+
export interface SkipLinkProps {
5+
/**
6+
* The text displayed for the skip link.
7+
*/
8+
linkText: string;
9+
/**
10+
* The id of the main content element to jump to.
11+
*/
12+
mainContentId: string;
13+
}
14+
15+
/**
16+
* Inserts a skip link as the first child of the element with ID 'root'.
17+
* When activated, focus is programmatically set to the main content.
18+
*/
19+
export function SkipLink({ linkText, mainContentId }: SkipLinkProps): null {
20+
useEffect(() => {
21+
// Create the skip link element
22+
const link = document.createElement("a");
23+
link.href = `#${mainContentId}`;
24+
link.className = "skip-link";
25+
link.textContent = linkText;
26+
link.tabIndex = 0;
27+
28+
// Handler to move focus to the main content
29+
function handleClick(event: MouseEvent) {
30+
event.preventDefault();
31+
const main = document.getElementById(mainContentId);
32+
if (main) {
33+
// Store previous tabindex
34+
const prevTabIndex = main.getAttribute("tabindex");
35+
// Ensure main is focusable
36+
if (!main.hasAttribute("tabindex")) {
37+
main.setAttribute("tabindex", "-1");
38+
}
39+
main.focus();
40+
// Clean up tabindex if it was not present before
41+
if (prevTabIndex === null) {
42+
main.addEventListener("blur", () => main.removeAttribute("tabindex"), { once: true });
43+
}
44+
}
45+
}
46+
47+
link.addEventListener("click", handleClick);
48+
49+
// Insert as the first child of the element with ID 'root'
50+
const root = document.getElementById("root");
51+
if (root) {
52+
root.insertBefore(link, root.firstChild);
53+
}
54+
55+
// Cleanup on unmount
56+
return () => {
57+
link.removeEventListener("click", handleClick);
58+
if (link.parentNode) {
59+
link.parentNode.removeChild(link);
60+
}
61+
};
62+
}, [linkText, mainContentId]);
63+
64+
// This component does not render anything in the React tree
65+
return null;
66+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?xml version="1.0" encoding="utf-8" ?>
2+
<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">
3+
<name>SkipLink</name>
4+
<description>A skip link for accessibility, allowing users to jump directly to the main content.</description>
5+
<studioProCategory>Accessibility</studioProCategory>
6+
<studioCategory>Accessibility</studioCategory>
7+
<helpUrl>https://docs.mendix.com/appstore/widgets/skiplink</helpUrl>
8+
<properties>
9+
<propertyGroup caption="General">
10+
<property key="linkText" type="string" defaultValue="Skip to main content">
11+
<caption>Link text</caption>
12+
<description>The text displayed for the skip link.</description>
13+
</property>
14+
<property key="mainContentId" type="string" defaultValue="main-content">
15+
<caption>Main content ID</caption>
16+
<description>The id of the main content element to jump to.</description>
17+
</property>
18+
</propertyGroup>
19+
</properties>
20+
</widget>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?xml version="1.0" encoding="utf-8" ?>
2+
<package xmlns="http://www.mendix.com/package/1.0/">
3+
<clientModule name="SkipLink" version="1.0.0" xmlns="http://www.mendix.com/clientModule/1.0/">
4+
<widgetFiles>
5+
<widgetFile path="SkipLink.xml" />
6+
</widgetFiles>
7+
<files>
8+
<file path="com/mendix/widget/web/skiplink" />
9+
</files>
10+
</clientModule>
11+
</package>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"include": ["./src", "./typings"],
3+
"compilerOptions": {
4+
"baseUrl": "./",
5+
"noEmitOnError": true,
6+
"sourceMap": true,
7+
"module": "esnext",
8+
"target": "es6",
9+
"lib": ["esnext", "dom"],
10+
"types": ["jest", "node"],
11+
"moduleResolution": "node",
12+
"declaration": false,
13+
"noLib": false,
14+
"forceConsistentCasingInFileNames": true,
15+
"noFallthroughCasesInSwitch": true,
16+
"strict": true,
17+
"strictFunctionTypes": false,
18+
"skipLibCheck": true,
19+
"noUnusedLocals": true,
20+
"noUnusedParameters": true,
21+
"jsx": "react-jsx",
22+
"outDir": "dist",
23+
"rootDir": "src"
24+
}
25+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* This file was generated from SkipLink.xml
3+
* WARNING: All changes made to this file will be overwritten
4+
* @author Mendix Widgets Framework Team
5+
*/
6+
import { CSSProperties } from "react";
7+
8+
export interface SkipLinkContainerProps {
9+
name: string;
10+
class: string;
11+
style?: CSSProperties;
12+
tabIndex?: number;
13+
linkText: string;
14+
mainContentId: string;
15+
}
16+
17+
export interface SkipLinkPreviewProps {
18+
/**
19+
* @deprecated Deprecated since version 9.18.0. Please use class property instead.
20+
*/
21+
className: string;
22+
class: string;
23+
style: string;
24+
styleObject?: CSSProperties;
25+
readOnly: boolean;
26+
renderMode: "design" | "xray" | "structure";
27+
translate: (text: string) => string;
28+
linkText: string;
29+
mainContentId: string;
30+
}

pnpm-lock.yaml

Lines changed: 26 additions & 31 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)