Skip to content

feat: initial setup of skiplink widget #1764

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
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
```
23 changes: 23 additions & 0 deletions packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { expect, test } from "@playwright/test";

// Assumes the test project renders <SkipLink /> and a <main id="main-content"> 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();
});
});
Empty file.
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")
};
55 changes: 55 additions & 0 deletions packages/pluggableWidgets/skiplink-web/package.json
Original file line number Diff line number Diff line change
@@ -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:*"
}
}
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,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;
}
Original file line number Diff line number Diff line change
@@ -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 (
<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>
);
}
return <div />;
};

export function getPreviewCss(): string {
return require("./ui/SkipLink.scss");
}
66 changes: 66 additions & 0 deletions packages/pluggableWidgets/skiplink-web/src/SkipLink.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
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>https://docs.mendix.com/appstore/widgets/skiplink</helpUrl>
<properties>
<propertyGroup caption="General">
<property key="linkText" type="string" defaultValue="Skip to main content">
<caption>Link text</caption>
<description>The text displayed for the skip link.</description>
</property>
<property key="mainContentId" type="string" defaultValue="main-content">
<caption>Main content ID</caption>
<description>The id of the main content element to jump to.</description>
</property>
</propertyGroup>
</properties>
</widget>
11 changes: 11 additions & 0 deletions packages/pluggableWidgets/skiplink-web/src/package.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8" ?>
<package xmlns="http://www.mendix.com/package/1.0/">
<clientModule name="SkipLink" version="1.0.0" xmlns="http://www.mendix.com/clientModule/1.0/">
<widgetFiles>
<widgetFile path="SkipLink.xml" />
</widgetFiles>
<files>
<file path="com/mendix/widget/web/skiplink" />
</files>
</clientModule>
</package>
20 changes: 20 additions & 0 deletions packages/pluggableWidgets/skiplink-web/src/ui/SkipLink.scss
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading