-
Notifications
You must be signed in to change notification settings - Fork 63
[WC-2946] 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
base: main
Are you sure you want to change the base?
Changes from all commits
d71607f
93c74e8
fa2060f
6815e12
52c763c
74d766d
bef09bd
95005b6
ef5e035
7ec99cd
fd33e2a
8230160
574c551
74ebca9
0ee3595
5958650
01492c8
73b48d0
de74789
d476c9b
29de627
d72d9d3
9f1fd25
a9450a1
bb34e80
dafc52d
ba4ff10
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| module.exports = require("@mendix/prettier-config-web-widgets"); |
| 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. |
| 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. |
| 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"); | ||
| }); | ||
| }); |
| 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; |
| 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") | ||
| }; |
| 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,30 @@ | ||
| import { ReactElement } from "react"; | ||
| import { SkipLinkPreviewProps } from "../typings/SkipLinkProps"; | ||
|
|
||
| export const preview = (props: SkipLinkPreviewProps): ReactElement => { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we want user to style this live in preview?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure, this is the first editorPreview I've made. So if there's improvements I'm all ears. |
||
| if (props.renderMode === "xray") { | ||
| return ( | ||
| <div style={{ position: "relative", height: 40 }}> | ||
| <a | ||
|
Check failure on line 8 in packages/pluggableWidgets/skiplink-web/src/SkipLink.editorPreview.tsx
|
||
| href={`#${props.mainContentId}`} | ||
| style={props.styleObject} | ||
| > | ||
| {props.linkText} | ||
| </a> | ||
| </div> | ||
| ); | ||
| } else { | ||
| return ( | ||
| <a | ||
|
Check failure on line 18 in packages/pluggableWidgets/skiplink-web/src/SkipLink.editorPreview.tsx
|
||
| href={`#${props.mainContentId}`} | ||
| style={props.styleObject} | ||
| > | ||
| {props.linkText} | ||
| </a> | ||
| ); | ||
| } | ||
| }; | ||
|
|
||
| export function getPreviewCss(): string { | ||
| return require("./ui/SkipLink.scss"); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| 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 | ||
| ); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.