Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
a145b0d
feat: initial setup of skiplink widget
HedwigJDoets Jul 11, 2025
921a5cc
feat(skiplink-web): adding configuration
HedwigJDoets Jul 11, 2025
e2b2c0e
chore: improve config files, ran linting
HedwigJDoets Jul 15, 2025
372fefd
fix: small improvements
HedwigJDoets Nov 12, 2025
0a1cb51
fix: rewrite tests for current version of the widget
HedwigJDoets Nov 12, 2025
5b51240
fix: add changes in pnpm lock file
HedwigJDoets Nov 13, 2025
97c2966
chore: update readme
HedwigJDoets Nov 24, 2025
910d05f
fix: fix lint issues
HedwigJDoets Nov 26, 2025
7bbb531
chore: add testproject info to package json
HedwigJDoets Nov 26, 2025
ae6eee7
fix: lint error on new testproject
HedwigJDoets Nov 26, 2025
5fa3671
chore: add changelog file
HedwigJDoets Nov 26, 2025
a7ea347
fix: fix changelog
HedwigJDoets Nov 26, 2025
d7eb3ee
fix: add whitespace to changelog
HedwigJDoets Nov 27, 2025
a90fa77
fix: add added header to changelog
HedwigJDoets Nov 27, 2025
ded729a
fix: remove react import
HedwigJDoets Nov 27, 2025
4dfe899
chore: add specific mendix version to e2e, add not required to xml
HedwigJDoets Nov 28, 2025
4c3d5e5
test: fix Docker mxbuild for ARM arch and skip the atlas theme copy
leonardomendix Dec 2, 2025
ff7537f
feat: add e2e test
HedwigJDoets Dec 4, 2025
4ae5d2d
fix: e2e tests
HedwigJDoets Dec 8, 2025
75883a6
fix: add screenshot for screenshot test
HedwigJDoets Dec 8, 2025
ef35ca7
fix: rename snaphot
HedwigJDoets Dec 8, 2025
de0d587
fix: remove screenshot
HedwigJDoets Dec 9, 2025
3e571bf
fix: readd screenshot
HedwigJDoets Dec 9, 2025
296117c
fix: refactor based on review comments
HedwigJDoets Dec 11, 2025
943d7fa
fix: apply lint fixes
HedwigJDoets Dec 18, 2025
0278a6f
fix: rename css class, fix click event type
HedwigJDoets Dec 18, 2025
fc8f5e9
fix: implement review comments
HedwigJDoets Dec 18, 2025
0685840
fix: ran linting
HedwigJDoets Dec 19, 2025
ebe01b4
fix: Add styling for editor preview
HedwigJDoets Dec 19, 2025
79ff479
fix: update skiplink selector in e2e tests
HedwigJDoets Dec 19, 2025
920412d
fix: update snapshot
HedwigJDoets Dec 19, 2025
7b0a51d
fix: e2e test
HedwigJDoets Dec 19, 2025
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.
74 changes: 74 additions & 0 deletions packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
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(".widget-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(".widget-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(".widget-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(".widget-skip-link").first();

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

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

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

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

const skipLink = page.locator(".widget-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,24 @@
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}`} className={"widget-skip-link-preview"} style={props.styleObject}>
{props.linkText}
</a>
</div>
);
} else {
return (
<a href={`#${props.mainContentId}`} className={"widget-skip-link-preview"} style={props.styleObject}>
{props.linkText}
</a>
);
}
};

export function getPreviewCss(): string {
return require("./ui/SkipLink.scss");
}
68 changes: 68 additions & 0 deletions packages/pluggableWidgets/skiplink-web/src/SkipLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { MouseEvent, useState } from "react";
import { createPortal } from "react-dom";
import "./ui/SkipLink.scss";
import { SkipLinkContainerProps } from "typings/SkipLinkProps";

/**
* 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(props: SkipLinkContainerProps) {
const [linkRoot] = useState(() => {
const link = document.createElement("div");
const root = document.getElementById("root");
// Insert as first child immediately
if (root && root.firstElementChild) {
root.insertBefore(link, root.firstElementChild);
} else if (root) {
root.appendChild(link);
} else {
console.error("No root element found on page");
}
return link;
});

function handleClick(event: MouseEvent): void {
event.preventDefault();
let main: HTMLElement;
if (props.mainContentId !== "") {
const mainByID = document.getElementById(props.mainContentId);
if (mainByID !== null) {
main = mainByID;
} else {
console.error(`Element with id: ${props.mainContentId} not found on page`);
return;
}
} 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 });
}
} else {
console.error("Could not find a main element on page and no mainContentId specified in widget properties.");
}
}

return createPortal(
<a
className={`widget-skip-link ${props.class}`}
href={`#${props.mainContentId}`}
tabIndex={props.tabIndex}
onClick={handleClick}
>
{props.linkText}
</a>,
linkRoot
);
}
Loading
Loading