Skip to content
Open
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
45 changes: 27 additions & 18 deletions packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,67 +8,76 @@ test.afterEach("Cleanup session", async ({ page }) => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Wait for the skip link to be attached to the DOM
await page.locator(".widget-skip-link").first().waitFor({ state: "attached" });
});

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)");

// Check initial styling (hidden) - transform is on the container, not the link
const container = page.locator(".widget-skip-link-container");
const transform = await container.evaluate(el => getComputedStyle(el).transform);
// Check for translateY(-120%) which appears as negative Y value in matrix
expect(transform).toMatch(/matrix.*-\d+/);
});

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)")

// Wait for the CSS transition to complete (0.2s in CSS + buffer)
await page.waitForTimeout(300);

// Check that the container becomes visible when focused
const container = page.locator(".widget-skip-link-container");
const transform = await container.evaluate(el => getComputedStyle(el).transform);
// When focused, translateY(0) results in matrix(1, 0, 0, 1, 0, 0)
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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,43 @@ import { ReactElement } from "react";
import { SkipLinkPreviewProps } from "../typings/SkipLinkProps";

export const preview = (props: SkipLinkPreviewProps): ReactElement => {
const hasListItems = props.listContentId && props.listContentId.length > 0;

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 style={{ position: "relative", minHeight: 40 }}>
<div
className="widget-skip-link-container"
style={{ position: "relative", transform: "none", ...props.styleObject }}
>
<a href={`#${props.mainContentId}`} className="widget-skip-link">
{`${props.skipToPrefix} ${props.linkText}`}
</a>
{hasListItems &&
props.listContentId.map((item, index) => (
<a key={index} href={`#${item.contentIdInList}`} className="widget-skip-link">
{`${props.skipToPrefix} ${item.LinkTextInList || item.contentIdInList}`}
</a>
))}
</div>
</div>
);
} else {
return (
<a href={`#${props.mainContentId}`} className={"widget-skip-link-preview"} style={props.styleObject}>
{props.linkText}
</a>
<div
className="widget-skip-link-container"
style={{ position: "relative", transform: "none", ...props.styleObject }}
>
<a href={`#${props.mainContentId}`} className="widget-skip-link">
{`${props.skipToPrefix} ${props.linkText}`}
</a>
{hasListItems &&
props.listContentId.map((item, index) => (
<a key={index} href={`#${item.contentIdInList}`} className="widget-skip-link">
{`${props.skipToPrefix} ${item.LinkTextInList || item.contentIdInList}`}
</a>
))}
</div>
);
}
};
Expand Down
77 changes: 14 additions & 63 deletions packages/pluggableWidgets/skiplink-web/src/SkipLink.tsx
Original file line number Diff line number Diff line change
@@ -1,68 +1,19 @@
import { MouseEvent, useState } from "react";
import { createPortal } from "react-dom";
import "./ui/SkipLink.scss";
import { ReactElement } from "react";
import { SkipLinkContainerProps } from "typings/SkipLinkProps";
import { SkipLinkComponent } from "./components/SkipLinkComponent";

/**
* 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;
});
export default function SkipLink(props: SkipLinkContainerProps): ReactElement {
const { linkText, mainContentId, listContentId, skipToPrefix, class: className, tabIndex, name } = props;

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
return (
<SkipLinkComponent
linkText={linkText}
mainContentId={mainContentId}
listContentId={listContentId}
skipToPrefix={skipToPrefix}
class={className}
tabIndex={tabIndex}
name={name}
/>
);
}
45 changes: 36 additions & 9 deletions packages/pluggableWidgets/skiplink-web/src/SkipLink.xml
Original file line number Diff line number Diff line change
@@ -1,20 +1,47 @@
<?xml version="1.0" encoding="utf-8" ?>
<widget id="com.mendix.widget.web.skiplink.SkipLink" pluginWidget="true" offlineCapable="true" supportedPlatform="Web" 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>
<name>Skip link</name>
<description>A skip link for accessibility, allowing users to jump directly to the main content.</description>
<studioProCategory>Accessibility</studioProCategory>
<studioCategory>Accessibility</studioCategory>
<helpUrl />
<properties>
<propertyGroup caption="General">
<property key="linkText" type="string" defaultValue="Skip to main content">
<caption>Link text</caption>
<description>The text displayed in the skip link.</description>
</property>
<property key="mainContentId" type="string" required="false">
<caption>Main content ID</caption>
<description>The id of the main content element to jump to, if left empty the skip link widget will search for a main tag on the page.</description>
</property>
<propertyGroup caption="Main skip link">
<property key="linkText" type="string" defaultValue="main content">
<caption>Link text</caption>
<description>The text displayed in the main skip link (e.g., "main content" will result in "Skip to main content")</description>
</property>
<property key="mainContentId" type="string" required="false">
<caption>Target element ID</caption>
<description>The ID of the element to move focus to (e.g, main). This should match the element's 'id' on the page. If empty, the widget will use the 'main' element if available.</description>
</property>
</propertyGroup>
<propertyGroup caption="Additional skip links">
<property key="listContentId" type="object" isList="true" required="false">
<caption>Skip links</caption>
<description>Additional skip link targets for navigation</description>
<properties>
<property key="LinkTextInList" type="string" required="true">
<caption>Link text</caption>
<category>General</category>
<description>The text displayed in the skip link (e.g., "navigation" will result in "Skip to navigation")</description>
</property>
<property key="contentIdInList" type="expression" required="true">
<caption>Target element ID</caption>
<category>General</category>
<description>The id of the content element to jump to</description>
<returnType type="String" />
</property>
</properties>
</property>
</propertyGroup>
<propertyGroup caption="Customization">
<property key="skipToPrefix" type="string" defaultValue="Skip to">
<caption>Skip to prefix</caption>
<description>The prefix text used for all skip links (e.g., "Skip to" results in "Skip to main content", "Skip to navigation")</description>
</property>
</propertyGroup>
</propertyGroup>
</properties>
</widget>
Loading
Loading