diff --git a/packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.js b/packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.js index e36eee53ad..f0770650b3 100644 --- a/packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.js +++ b/packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.js @@ -8,6 +8,8 @@ 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 () { @@ -15,34 +17,41 @@ test.describe("SkipLink:", function () { // 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(); @@ -50,13 +59,13 @@ test.describe("SkipLink:", function () { 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"); }); @@ -64,11 +73,11 @@ test.describe("SkipLink:", function () { 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"); }); -}); \ No newline at end of file +}); diff --git a/packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.js-snapshots/skiplink-focused-chromium-darwin.png b/packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.js-snapshots/skiplink-focused-chromium-darwin.png new file mode 100644 index 0000000000..279ca41157 Binary files /dev/null and b/packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.js-snapshots/skiplink-focused-chromium-darwin.png differ diff --git a/packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.js-snapshots/skiplink-focused-chromium-linux.png b/packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.js-snapshots/skiplink-focused-chromium-linux.png index 34157374d2..279ca41157 100644 Binary files a/packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.js-snapshots/skiplink-focused-chromium-linux.png and b/packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.js-snapshots/skiplink-focused-chromium-linux.png differ diff --git a/packages/pluggableWidgets/skiplink-web/src/SkipLink.editorPreview.tsx b/packages/pluggableWidgets/skiplink-web/src/SkipLink.editorPreview.tsx index 45f145efd9..1053e3ebad 100644 --- a/packages/pluggableWidgets/skiplink-web/src/SkipLink.editorPreview.tsx +++ b/packages/pluggableWidgets/skiplink-web/src/SkipLink.editorPreview.tsx @@ -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 ( -
- - {props.linkText} - +
+
+ + {`${props.skipToPrefix} ${props.linkText}`} + + {hasListItems && + props.listContentId.map((item, index) => ( + + {`${props.skipToPrefix} ${item.LinkTextInList || item.contentIdInList}`} + + ))} +
); } else { return ( - - {props.linkText} - +
+ + {`${props.skipToPrefix} ${props.linkText}`} + + {hasListItems && + props.listContentId.map((item, index) => ( + + {`${props.skipToPrefix} ${item.LinkTextInList || item.contentIdInList}`} + + ))} +
); } }; diff --git a/packages/pluggableWidgets/skiplink-web/src/SkipLink.tsx b/packages/pluggableWidgets/skiplink-web/src/SkipLink.tsx index c1a10cdfde..655b209f8e 100644 --- a/packages/pluggableWidgets/skiplink-web/src/SkipLink.tsx +++ b/packages/pluggableWidgets/skiplink-web/src/SkipLink.tsx @@ -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( - - {props.linkText} - , - linkRoot + return ( + ); } diff --git a/packages/pluggableWidgets/skiplink-web/src/SkipLink.xml b/packages/pluggableWidgets/skiplink-web/src/SkipLink.xml index ab4e229700..0ea569831f 100644 --- a/packages/pluggableWidgets/skiplink-web/src/SkipLink.xml +++ b/packages/pluggableWidgets/skiplink-web/src/SkipLink.xml @@ -1,20 +1,47 @@ - SkipLink + Skip link A skip link for accessibility, allowing users to jump directly to the main content. Accessibility Accessibility - - Link text - The text displayed in the skip link. - - - Main content ID - 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. - + + + Link text + The text displayed in the main skip link (e.g., "main content" will result in "Skip to main content") + + + Target element ID + 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. + + + + + Skip links + Additional skip link targets for navigation + + + Link text + General + The text displayed in the skip link (e.g., "navigation" will result in "Skip to navigation") + + + Target element ID + General + The id of the content element to jump to + + + + + + + + Skip to prefix + The prefix text used for all skip links (e.g., "Skip to" results in "Skip to main content", "Skip to navigation") + + diff --git a/packages/pluggableWidgets/skiplink-web/src/__tests__/SkipLink.spec.tsx b/packages/pluggableWidgets/skiplink-web/src/__tests__/SkipLink.spec.tsx index 69464093d6..afa2887429 100644 --- a/packages/pluggableWidgets/skiplink-web/src/__tests__/SkipLink.spec.tsx +++ b/packages/pluggableWidgets/skiplink-web/src/__tests__/SkipLink.spec.tsx @@ -1,7 +1,8 @@ import "@testing-library/jest-dom"; -import { render } from "@testing-library/react"; -import { SkipLinkContainerProps } from "../../typings/SkipLinkProps"; -import { SkipLink } from "../SkipLink"; +import { render, fireEvent, waitFor } from "@testing-library/react"; +import { DynamicValue, ValueStatus } from "mendix"; +import { SkipLinkContainerProps, ListContentIdType } from "../../typings/SkipLinkProps"; +import SkipLink from "../SkipLink"; describe("SkipLink", () => { let defaultProps: SkipLinkContainerProps; @@ -18,8 +19,11 @@ describe("SkipLink", () => { name: "SkipLink1", class: "mx-skiplink", style: {}, - linkText: "Skip to main content", - mainContentId: "main-content" + linkText: "main content", + mainContentId: "main-content", + skipToPrefix: "Skip to", + listContentId: [], + tabIndex: 0 }; }); @@ -27,58 +31,443 @@ describe("SkipLink", () => { document.body.innerHTML = ""; }); - it("renders skiplink widget and adds skip link to DOM", () => { - render(); + describe("Rendering", () => { + it("renders skiplink widget and adds skip link to DOM", () => { + render(); - // Check that the skip link was added to the root element - const skipLink = rootElement.querySelector(".widget-skip-link") as HTMLAnchorElement; - expect(skipLink).toBeInTheDocument(); - expect(skipLink.textContent).toBe("Skip to main content"); - expect(skipLink.href).toBe(`${window.location.origin}/#main-content`); - expect(skipLink.tabIndex).toBe(0); + const skipLink = rootElement.querySelector(".widget-skip-link") as HTMLAnchorElement; + expect(skipLink).toBeInTheDocument(); + expect(skipLink.textContent).toBe("Skip to main content"); + expect(skipLink.href).toBe(`${window.location.origin}/#main-content`); + expect(skipLink.tabIndex).toBe(0); + }); - // Snapshot the actual root element that contains the skip link - expect(rootElement).toMatchSnapshot(); + it("renders with custom link text", () => { + render(); + + const skipLink = rootElement.querySelector(".widget-skip-link") as HTMLAnchorElement; + expect(skipLink).toBeInTheDocument(); + expect(skipLink.textContent).toBe("Skip to content area"); + }); + + it("renders with custom skip to prefix", () => { + render(); + + const skipLink = rootElement.querySelector(".widget-skip-link") as HTMLAnchorElement; + expect(skipLink).toBeInTheDocument(); + expect(skipLink.textContent).toBe("Jump to content"); + }); + + it("renders with custom main content id", () => { + render(); + + const skipLink = rootElement.querySelector(".widget-skip-link") as HTMLAnchorElement; + expect(skipLink).toBeInTheDocument(); + expect(skipLink.href).toBe(`${window.location.origin}/#content-area`); + }); + + it("renders with empty main content id", () => { + render(); + + const skipLink = rootElement.querySelector(".widget-skip-link") as HTMLAnchorElement; + expect(skipLink).toBeInTheDocument(); + expect(skipLink.href).toBe(`${window.location.origin}/#`); + }); + + it("inserts skip link as first child of root element", () => { + // Add some existing content to root + const existingDiv = document.createElement("div"); + existingDiv.id = "existing-content"; + rootElement.appendChild(existingDiv); + + render(); + + // Skip link container should be first child + expect(rootElement.firstElementChild?.querySelector(".widget-skip-link-container")).toBeInTheDocument(); + }); + + it("logs error when root element is not found", () => { + // Remove the root element + document.body.removeChild(rootElement); + + const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); + + render(); + + expect(consoleErrorSpy).toHaveBeenCalledWith("No root element found on page"); + + consoleErrorSpy.mockRestore(); + + // Restore root element for other tests + rootElement = document.createElement("div"); + rootElement.id = "root"; + document.body.appendChild(rootElement); + }); + }); + + describe("Multiple Skip Links", () => { + it("renders multiple skip links from listContentId", () => { + const listContentId: ListContentIdType[] = [ + { + contentIdInList: { + status: ValueStatus.Available, + value: "navigation" + } as DynamicValue, + LinkTextInList: "navigation menu" + }, + { + contentIdInList: { + status: ValueStatus.Available, + value: "search" + } as DynamicValue, + LinkTextInList: "search form" + } + ]; + + render(); + + const skipLinks = rootElement.querySelectorAll(".widget-skip-link"); + expect(skipLinks).toHaveLength(3); // Main + 2 additional + + expect(skipLinks[0].textContent).toBe("Skip to main content"); + expect(skipLinks[1].textContent).toBe("Skip to navigation menu"); + expect(skipLinks[2].textContent).toBe("Skip to search form"); + + expect((skipLinks[1] as HTMLAnchorElement).href).toBe(`${window.location.origin}/#navigation`); + expect((skipLinks[2] as HTMLAnchorElement).href).toBe(`${window.location.origin}/#search`); + }); + + it("filters out unavailable list items", () => { + const listContentId: ListContentIdType[] = [ + { + contentIdInList: { + status: ValueStatus.Available, + value: "navigation" + } as DynamicValue, + LinkTextInList: "navigation menu" + }, + { + contentIdInList: { + status: ValueStatus.Loading, + value: undefined + } as DynamicValue, + LinkTextInList: "loading item" + }, + { + contentIdInList: { + status: ValueStatus.Available, + value: "footer" + } as DynamicValue, + LinkTextInList: "footer" + } + ]; + + render(); + + const skipLinks = rootElement.querySelectorAll(".widget-skip-link"); + expect(skipLinks).toHaveLength(3); // Main + 2 available items (loading item filtered out) + + expect(skipLinks[0].textContent).toBe("Skip to main content"); + expect(skipLinks[1].textContent).toBe("Skip to navigation menu"); + expect(skipLinks[2].textContent).toBe("Skip to footer"); + }); + + it("filters out list items with empty values", () => { + const listContentId: ListContentIdType[] = [ + { + contentIdInList: { + status: ValueStatus.Available, + value: "navigation" + } as DynamicValue, + LinkTextInList: "navigation menu" + }, + { + contentIdInList: { + status: ValueStatus.Available, + value: "" + } as DynamicValue, + LinkTextInList: "empty item" + } + ]; + + render(); + + const skipLinks = rootElement.querySelectorAll(".widget-skip-link"); + expect(skipLinks).toHaveLength(2); // Main + 1 valid item (empty value filtered out) + }); + }); + + describe("Visibility Behavior", () => { + it("container has the correct CSS class for visibility behavior", () => { + render(); + + const container = rootElement.querySelector(".widget-skip-link-container") as HTMLElement; + expect(container).toBeInTheDocument(); + expect(container).toHaveClass("widget-skip-link-container"); + // The CSS transform is applied via the class in SkipLink.scss + // Actual visual hiding is tested in E2E tests + }); + + it("becomes visible when skip link receives focus", () => { + render(); + + const skipLink = rootElement.querySelector(".widget-skip-link") as HTMLAnchorElement; + const container = rootElement.querySelector(".widget-skip-link-container") as HTMLElement; + + // Focus the skip link + skipLink.focus(); + + // Container should have :focus-within, which applies translateY(0) + // We can verify the skip link has focus + expect(skipLink).toHaveFocus(); + + // The container should have the :focus-within pseudo-class active + // We can check if container is an ancestor of the focused element + expect(container.contains(document.activeElement)).toBe(true); + }); + + it("shows all skip links when any link is focused", () => { + const listContentId: ListContentIdType[] = [ + { + contentIdInList: { + status: ValueStatus.Available, + value: "navigation" + } as DynamicValue, + LinkTextInList: "navigation menu" + } + ]; + + render(); + + const skipLinks = rootElement.querySelectorAll(".widget-skip-link"); + const container = rootElement.querySelector(".widget-skip-link-container") as HTMLElement; + + // Focus the second skip link + (skipLinks[1] as HTMLAnchorElement).focus(); + + expect(skipLinks[1]).toHaveFocus(); + expect(container.contains(document.activeElement)).toBe(true); + }); }); - it("renders with custom link text", () => { - render(); + describe("Focus Management", () => { + it("moves focus to target element when clicked", () => { + // Create target element + const mainContent = document.createElement("div"); + mainContent.id = "main-content"; + document.body.appendChild(mainContent); + + render(); + + const skipLink = rootElement.querySelector(".widget-skip-link") as HTMLAnchorElement; + + // Click the skip link + fireEvent.click(skipLink); + + // Target element should receive focus + expect(mainContent).toHaveFocus(); + expect(mainContent.getAttribute("tabindex")).toBe("-1"); + }); + + it("adds tabindex=-1 to non-focusable target element", () => { + const mainContent = document.createElement("div"); + mainContent.id = "main-content"; + document.body.appendChild(mainContent); + + render(); - const skipLink = rootElement.querySelector(".widget-skip-link") as HTMLAnchorElement; - expect(skipLink).toBeInTheDocument(); - expect(skipLink.textContent).toBe("Jump to content"); + const skipLink = rootElement.querySelector(".widget-skip-link") as HTMLAnchorElement; - expect(rootElement).toMatchSnapshot(); + // Initially no tabindex + expect(mainContent.hasAttribute("tabindex")).toBe(false); + + fireEvent.click(skipLink); + + // After click, tabindex should be added + expect(mainContent.getAttribute("tabindex")).toBe("-1"); + expect(mainContent).toHaveFocus(); + }); + + it("preserves existing tabindex on target element", () => { + const mainContent = document.createElement("div"); + mainContent.id = "main-content"; + mainContent.setAttribute("tabindex", "0"); + document.body.appendChild(mainContent); + + render(); + + const skipLink = rootElement.querySelector(".widget-skip-link") as HTMLAnchorElement; + + fireEvent.click(skipLink); + + // Existing tabindex should be preserved + expect(mainContent.getAttribute("tabindex")).toBe("0"); + expect(mainContent).toHaveFocus(); + }); + + it("removes temporary tabindex when target element loses focus", async () => { + const mainContent = document.createElement("div"); + mainContent.id = "main-content"; + document.body.appendChild(mainContent); + + render(); + + const skipLink = rootElement.querySelector(".widget-skip-link") as HTMLAnchorElement; + + // Click to set focus + fireEvent.click(skipLink); + expect(mainContent.getAttribute("tabindex")).toBe("-1"); + + // Blur the target element + fireEvent.blur(mainContent); + + // Wait for blur event handler to execute + await waitFor(() => { + expect(mainContent.hasAttribute("tabindex")).toBe(false); + }); + }); + + it("focuses fallback
element when mainContentId is empty", () => { + const mainElement = document.createElement("main"); + document.body.appendChild(mainElement); + + render(); + + const skipLink = rootElement.querySelector(".widget-skip-link") as HTMLAnchorElement; + + fireEvent.click(skipLink); + + expect(mainElement).toHaveFocus(); + }); + + it("focuses correct target when list item is clicked", () => { + const navigation = document.createElement("nav"); + navigation.id = "navigation"; + document.body.appendChild(navigation); + + const listContentId: ListContentIdType[] = [ + { + contentIdInList: { + status: ValueStatus.Available, + value: "navigation" + } as DynamicValue, + LinkTextInList: "navigation menu" + } + ]; + + render(); + + const skipLinks = rootElement.querySelectorAll(".widget-skip-link"); + const navSkipLink = skipLinks[1] as HTMLAnchorElement; + + fireEvent.click(navSkipLink); + + expect(navigation).toHaveFocus(); + }); + + it("logs error when target element is not found", () => { + const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); + + render(); + + const skipLink = rootElement.querySelector(".widget-skip-link") as HTMLAnchorElement; + + fireEvent.click(skipLink); + + expect(consoleErrorSpy).toHaveBeenCalledWith("Element with id: non-existent not found on page"); + + consoleErrorSpy.mockRestore(); + }); + + it("logs error when no main element found and mainContentId is empty", () => { + const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); + + render(); + + const skipLink = rootElement.querySelector(".widget-skip-link") as HTMLAnchorElement; + + fireEvent.click(skipLink); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Could not find a main element on page and no mainContentId specified in widget properties." + ); + + consoleErrorSpy.mockRestore(); + }); + + it("prevents default link behavior on click", () => { + const mainContent = document.createElement("div"); + mainContent.id = "main-content"; + document.body.appendChild(mainContent); + + render(); + + const skipLink = rootElement.querySelector(".widget-skip-link") as HTMLAnchorElement; + + const clickEvent = new MouseEvent("click", { bubbles: true, cancelable: true }); + const preventDefaultSpy = jest.spyOn(clickEvent, "preventDefault"); + + skipLink.dispatchEvent(clickEvent); + + expect(preventDefaultSpy).toHaveBeenCalled(); + }); }); - it("renders with custom main content id", () => { - render(); + describe("Focus Indicator Styling", () => { + it("applies focus styles when skip link is focused", () => { + render(); - const skipLink = rootElement.querySelector(".widget-skip-link") as HTMLAnchorElement; - expect(skipLink).toBeInTheDocument(); - expect(skipLink.href).toBe(`${window.location.origin}/#content-area`); + const skipLink = rootElement.querySelector(".widget-skip-link") as HTMLAnchorElement; - expect(rootElement).toMatchSnapshot(); + skipLink.focus(); + + expect(skipLink).toHaveFocus(); + // The CSS :focus pseudo-class applies outline and background-color + // We can verify the element has focus, actual styling is tested via E2E + }); }); - it("renders with empty main content id", () => { - render(); + describe("Cleanup", () => { + it("cleans up skip link when component unmounts", () => { + const { unmount } = render(); + + // Verify skip link is present + expect(rootElement.querySelector(".widget-skip-link")).toBeInTheDocument(); + + // Unmount and verify cleanup + unmount(); + expect(rootElement.querySelector(".widget-skip-link")).not.toBeInTheDocument(); + expect(rootElement.querySelector(".widget-skip-link-container")).not.toBeInTheDocument(); + }); - const skipLink = rootElement.querySelector(".widget-skip-link") as HTMLAnchorElement; - expect(skipLink).toBeInTheDocument(); - expect(skipLink.href).toBe(`${window.location.origin}/#`); + it("removes portal element from DOM on unmount", () => { + const { unmount } = render(); - expect(rootElement).toMatchSnapshot(); + // Portal should be inserted + const portalContainer = rootElement.querySelector(".widget-skip-link-container")?.parentElement; + expect(portalContainer).toBeInTheDocument(); + + unmount(); + + // Portal container should be removed + expect(document.body.contains(portalContainer!)).toBe(false); + }); }); - it("cleans up skip link when component unmounts", () => { - const { unmount } = render(); + describe("Custom Styling", () => { + it("applies custom className to skip links", () => { + render(); + + const skipLink = rootElement.querySelector(".widget-skip-link") as HTMLAnchorElement; + expect(skipLink).toHaveClass("widget-skip-link"); + expect(skipLink).toHaveClass("custom-class"); + }); - // Verify skip link is present - expect(rootElement.querySelector(".widget-skip-link")).toBeInTheDocument(); + it("applies custom tabIndex to skip links", () => { + render(); - // Unmount and verify cleanup - unmount(); - expect(rootElement.querySelector(".widget-skip-link")).not.toBeInTheDocument(); + const skipLink = rootElement.querySelector(".widget-skip-link") as HTMLAnchorElement; + expect(skipLink.tabIndex).toBe(5); + }); }); }); diff --git a/packages/pluggableWidgets/skiplink-web/src/__tests__/__snapshots__/SkipLink.spec.tsx.snap b/packages/pluggableWidgets/skiplink-web/src/__tests__/__snapshots__/SkipLink.spec.tsx.snap deleted file mode 100644 index ca19852b84..0000000000 --- a/packages/pluggableWidgets/skiplink-web/src/__tests__/__snapshots__/SkipLink.spec.tsx.snap +++ /dev/null @@ -1,61 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`SkipLink renders skiplink widget and adds skip link to DOM 1`] = ` - -`; - -exports[`SkipLink renders with custom link text 1`] = ` - -`; - -exports[`SkipLink renders with custom main content id 1`] = ` - -`; - -exports[`SkipLink renders with empty main content id 1`] = ` - -`; diff --git a/packages/pluggableWidgets/skiplink-web/src/components/SkipLinkComponent.tsx b/packages/pluggableWidgets/skiplink-web/src/components/SkipLinkComponent.tsx new file mode 100644 index 0000000000..6c7e36997b --- /dev/null +++ b/packages/pluggableWidgets/skiplink-web/src/components/SkipLinkComponent.tsx @@ -0,0 +1,95 @@ +import { MouseEvent, useState, ReactElement, useEffect, useMemo } from "react"; +import { createPortal } from "react-dom"; +import { ValueStatus } from "mendix"; +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 SkipLinkComponent(props: SkipLinkContainerProps): ReactElement { + const { skipToPrefix, linkText, mainContentId, tabIndex, listContentId, class: className } = props; + const [linkRoot] = useState(() => document.createElement("div")); + const mainLinkText = useMemo(() => `${skipToPrefix} ${linkText}`, [skipToPrefix, linkText]); + + useEffect(() => { + const root = document.getElementById("root"); + // Insert as first child immediately + if (root && root.firstElementChild) { + root.insertBefore(linkRoot, root.firstElementChild); + } else if (root) { + root.appendChild(linkRoot); + } else { + console.error("No root element found on page"); + } + return () => { + linkRoot.remove(); + }; + }, [linkRoot]); + + function handleClick(event: MouseEvent, contentId?: string): void { + event.preventDefault(); + let main: HTMLElement; + const targetId = contentId || mainContentId; + + if (targetId !== "") { + const mainByID = document.getElementById(targetId); + if (mainByID !== null) { + main = mainByID; + } else { + console.error(`Element with id: ${targetId} 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( +
+ handleClick(e)} + > + {mainLinkText} + + {listContentId + .filter(item => item.contentIdInList.status === ValueStatus.Available && item.contentIdInList.value) + .map((item, index) => { + const contentId = item.contentIdInList.value!; + const linkText = `${skipToPrefix} ${item.LinkTextInList}`; + return ( + handleClick(e, contentId)} + > + {linkText} + + ); + })} +
, + linkRoot + ); +} diff --git a/packages/pluggableWidgets/skiplink-web/src/ui/SkipLink.scss b/packages/pluggableWidgets/skiplink-web/src/ui/SkipLink.scss index 7ad05d3a43..b6ed891c93 100644 --- a/packages/pluggableWidgets/skiplink-web/src/ui/SkipLink.scss +++ b/packages/pluggableWidgets/skiplink-web/src/ui/SkipLink.scss @@ -1,17 +1,30 @@ -.widget-skip-link { +$skp-brand-secondary-light: #2dbafc; +$skp-grey-primary: #ced0d3; +$skp-brand-primary: #264ae5; + +.widget-skip-link-container { position: absolute; - top: 0; - left: 0; - background: #fff; - color: #0078d4; - padding: 8px 16px; + top: 6px; + left: 116px; z-index: 1000; + display: flex; + flex-direction: column; + background: #fff; + border: 1px solid var(--grey-primary, $skp-grey-primary); + border-radius: 4px; transform: translateY(-120%); transition: transform 0.2s; +} + +.widget-skip-link { + color: var(--brand-primary, $skp-brand-primary); + padding: 8px 16px; + border-bottom: 1px solid var(--grey-primary, $skp-grey-primary); text-decoration: none; - border: 2px solid #0078d4; - border-radius: 4px; - font-weight: bold; + + &:last-child { + border-bottom: none; + } } .widget-skip-link-preview { @@ -29,6 +42,11 @@ } .widget-skip-link:focus { + outline: 2px solid var(--brand-secondary-light, $skp-brand-secondary-light); + outline-offset: -2px; + background-color: rgba(45, 186, 252, 0.1); +} + +.widget-skip-link-container:focus-within { transform: translateY(0); - outline: none; } diff --git a/packages/pluggableWidgets/skiplink-web/typings/SkipLinkProps.d.ts b/packages/pluggableWidgets/skiplink-web/typings/SkipLinkProps.d.ts index dd4d4c8a82..27e2b1ecb1 100644 --- a/packages/pluggableWidgets/skiplink-web/typings/SkipLinkProps.d.ts +++ b/packages/pluggableWidgets/skiplink-web/typings/SkipLinkProps.d.ts @@ -4,6 +4,17 @@ * @author Mendix Widgets Framework Team */ import { CSSProperties } from "react"; +import { DynamicValue } from "mendix"; + +export interface ListContentIdType { + LinkTextInList: string; + contentIdInList: DynamicValue; +} + +export interface ListContentIdPreviewType { + LinkTextInList: string; + contentIdInList: string; +} export interface SkipLinkContainerProps { name: string; @@ -12,6 +23,8 @@ export interface SkipLinkContainerProps { tabIndex?: number; linkText: string; mainContentId: string; + listContentId: ListContentIdType[]; + skipToPrefix: string; } export interface SkipLinkPreviewProps { @@ -27,4 +40,6 @@ export interface SkipLinkPreviewProps { translate: (text: string) => string; linkText: string; mainContentId: string; + listContentId: ListContentIdPreviewType[]; + skipToPrefix: string; }