Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
6 changes: 4 additions & 2 deletions automation/run-e2e/docker/mxbuild.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@ echo "Downloading mxbuild ${MENDIX_VERSION} and docker building for ${BUILDPLATF
&& tar xfz /tmp/mxbuild.tar.gz --directory /tmp/mxbuild \
&& rm /tmp/mxbuild.tar.gz && \
\
apt-get update -qqy && \
apt-get install -qqy libicu70 && \
rm -rf /var/lib/apt/lists/* && \
apt-get update --allow-insecure-repositories -qqy && \
apt-get install -qqy --allow-unauthenticated libicu70 && \
apt-get -qqy remove --auto-remove wget && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* && \
\
echo "#!/bin/bash -x" >/bin/mxbuild && \
echo "/tmp/mxbuild/modeler/mxbuild --java-home=/opt/java/openjdk --java-exe-path=/opt/java/openjdk/bin/java \$@" >>/bin/mxbuild && \
Expand Down
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");
11 changes: 11 additions & 0 deletions packages/pluggableWidgets/skiplink-web/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Changelog

All notable changes to this widget will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- Created skiplink widget.
22 changes: 22 additions & 0 deletions packages/pluggableWidgets/skiplink-web/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Skip Link

Adds a skip navigation link for keyboard accessibility. The link is hidden until focused and allows users to jump directly to the main content.

## Usage

1. Add the Skip Link widget anywhere on your page, preferrably at the top or in a layout.
2. Configure the **Link Text** and **Main Content ID** properties.
3. Ensure your main content element has the specified ID, or there's a main tag on the page.

The widget automatically inserts the skip link as the first child of the `#root` element.

## Properties

- **Link Text**: Text displayed for the skip link (default: "Skip to main content").
- **Main Content ID**: ID of the main content element to focus (optional).

If the target element is not found, the widget will focus the first `<main>` element instead.

## Accessibility

The skip link is positioned absolutely at the top-left of the page, hidden by default with `transform: translateY(-120%)`, and becomes visible when focused via keyboard navigation.
77 changes: 77 additions & 0 deletions packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { test, expect } from "@playwright/test";

test.afterEach("Cleanup session", async ({ page }) => {
// Because the test isolation that will open a new session for every test executed, and that exceeds Mendix's license limit of 5 sessions, so we need to force logout after each test.
await page.evaluate(() => window.mx.session.logout());
});

test.beforeEach(async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
});

test.describe("SkipLink:", function () {
test("skip link is present in DOM but initially hidden", async ({ page }) => {
// Skip link should be in the DOM but not visible
const skipLink = page.locator(".skip-link").first();
await expect(skipLink).toBeAttached();

// Check initial styling (hidden)
const transform = await skipLink.evaluate(el => getComputedStyle(el).transform);
expect(transform).toContain("matrix(1, 0, 0, 1, 0, -48)");
});

test("skip link becomes visible when focused via keyboard", async ({ page }) => {
// Tab to focus the skip link (should be first focusable element)
const skipLink = page.locator(".skip-link").first();
await page.keyboard.press("Tab");

await expect(skipLink).toBeFocused();
await page.waitForTimeout(1000);
// Check that it becomes visible when focused
const transform = await skipLink.evaluate(el => getComputedStyle(el).transform);
expect(transform).toContain("matrix(1, 0, 0, 1, 0, 0)")
});

test("skip link navigates to main content when activated", async ({ page }) => {
// Tab to focus the skip link
await page.keyboard.press("Tab");

const skipLink = page.locator(".skip-link").first();
await expect(skipLink).toBeFocused();

// Activate the skip link
await page.keyboard.press("Enter");

// Check that main content is now focused
const mainContent = page.locator("main");
await expect(mainContent).toBeFocused();
});

test("skip link has correct attributes and text", async ({ page }) => {
const skipLink = page.locator(".skip-link").first();

// Check default text
await expect(skipLink).toHaveText("Skip to main content");

// Check href attribute
await expect(skipLink).toHaveAttribute("href", "#");

// Check tabindex
await expect(skipLink).toHaveAttribute("tabindex", "0");

// Check CSS class
await expect(skipLink).toHaveClass("skip-link");
});

test("visual comparison", async ({ page }) => {
// Tab to make skip link visible for screenshot
await page.keyboard.press("Tab");

const skipLink = page.locator(".skip-link").first();
await expect(skipLink).toBeFocused();

// Visual comparison of focused skip link
await expect(skipLink).toHaveScreenshot("skiplink-focused.png");
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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")
};
60 changes: 60 additions & 0 deletions packages/pluggableWidgets/skiplink-web/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{
"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
},
"testProject": {
"githubUrl": "https://github.com/mendix/testProjects",
"branchName": "skiplink-web"
},
"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": "MENDIX_VERSION=11.1.0.75979 run-e2e ci --no-update-project",
"e2edev": "MENDIX_VERSION=11.1.0.75979 run-e2e dev --with-preps --no-update-project",
"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 {
ContainerProps,
RowLayoutProps,
structurePreviewPalette,
StructurePreviewProps,
TextProps
} 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,54 @@
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
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>
);
}
};

export function getPreviewCss(): string {
return require("./ui/SkipLink.scss");
}
Loading
Loading