From fe92911b6b6d5c906360d75be130e63021c644fa Mon Sep 17 00:00:00 2001 From: "hedwig.doets" Date: Thu, 9 Apr 2026 10:19:00 +0200 Subject: [PATCH 1/5] feat: update SkipLink widget with design and refactor --- .../skiplink-web/SKIPLINK-SKILL.md | 558 ++++++++++++++++++ .../skiplink-web/e2e/SkipLink.spec.js | 24 +- .../src/SkipLink.editorPreview.tsx | 38 +- .../skiplink-web/src/SkipLink.tsx | 77 +-- .../skiplink-web/src/SkipLink.xml | 45 +- .../src/components/SkipLinkComponent.tsx | 95 +++ .../skiplink-web/src/ui/SkipLink.scss | 38 +- .../skiplink-web/typings/SkipLinkProps.d.ts | 15 + 8 files changed, 789 insertions(+), 101 deletions(-) create mode 100644 packages/pluggableWidgets/skiplink-web/SKIPLINK-SKILL.md create mode 100644 packages/pluggableWidgets/skiplink-web/src/components/SkipLinkComponent.tsx diff --git a/packages/pluggableWidgets/skiplink-web/SKIPLINK-SKILL.md b/packages/pluggableWidgets/skiplink-web/SKIPLINK-SKILL.md new file mode 100644 index 0000000000..5359b20956 --- /dev/null +++ b/packages/pluggableWidgets/skiplink-web/SKIPLINK-SKILL.md @@ -0,0 +1,558 @@ +# SkipLink Widget - AI Skill Document + +## Overview + +The SkipLink widget is a Mendix accessibility widget that provides keyboard users with the ability to skip directly to main content areas on a page, bypassing repetitive navigation elements. This is a critical WCAG 2.1 Level A accessibility requirement (Success Criterion 2.4.1 - Bypass Blocks). + +## Core Functionality + +### Purpose + +- Allows keyboard users to bypass repetitive content (headers, navigation menus, etc.) +- Improves accessibility for screen reader users +- Provides quick navigation to important page sections +- Supports WCAG 2.1 AA compliance + +### Behavior + +1. **Hidden by default**: Skip links are positioned off-screen using CSS transforms +2. **Visible on focus**: When a user tabs to a skip link, the entire container slides into view +3. **Multiple targets**: Supports one main skip target plus multiple additional targets via a list +4. **Focus management**: Programmatically sets focus to the target element and ensures it's keyboard-accessible + +## Architecture + +### Component Structure + +``` +SkipLink.tsx (Container Component) +└── SkipLinkComponent.tsx (Presentation Component) + ├── Portal Rendering (inserted as first child of #root) + ├── Container Div (.widget-skip-link-container) + │ ├── Main Skip Link (primary link) + │ └── Additional Skip Links (from listContentId array) +``` + +**Separation of Concerns:** +- `SkipLink.tsx`: Container component, handles prop destructuring and passes to presentation component +- `SkipLinkComponent.tsx`: Presentation component, handles DOM manipulation, rendering, and user interactions + +### Key Files + +- **SkipLink.tsx**: Container component (default export) +- **components/SkipLinkComponent.tsx**: Presentation component with portal logic +- **SkipLink.xml**: Widget configuration (properties schema) +- **SkipLink.scss**: Styling with show/hide behavior +- **SkipLink.editorPreview.tsx**: Studio Pro preview rendering +- **SkipLinkProps.d.ts**: Generated TypeScript types from XML + +## Configuration Properties + +### XML Schema Properties + +**Property Groups:** + +- **Main skip link**: Configuration for the primary skip link +- **Additional skip links**: List of additional navigation targets +- **Customization**: Global settings like prefix text + +**Individual Properties:** + +1. **linkText** (string, default: "main content") + - Text displayed for the main skip link (without prefix) + - Combined with `skipToPrefix` to form complete link text + - Example: "main content" becomes "Skip to main content" + - User-configurable and translatable + +2. **mainContentId** (string, optional) + - ID of the main content element to jump to + - If empty, widget searches for `
` tag + - Fallback behavior ensures accessibility + +3. **listContentId** (array of objects, optional) + - List of additional skip link targets + - Each item contains: + - `LinkTextInList`: Custom text for the skip link (without prefix) + - `contentIdInList`: Expression returning element ID (DynamicValue) + - All list items are rendered below the main skip link + +4. **skipToPrefix** (string, default: "Skip to") + - Prefix text used for ALL skip links (main + list items) + - Allows translation/customization for internationalization + - Applied to both main link and additional links + - Example: Change to "Ga naar" for Dutch translation + +### TypeScript Props Interface + +```typescript +export interface SkipLinkContainerProps { + name: string; + class: string; + style?: CSSProperties; + tabIndex?: number; + linkText: string; + mainContentId: string; + listContentId: ListContentIdType[]; + skipToPrefix: string; +} + +export interface ListContentIdType { + contentIdInList: DynamicValue; + LinkTextInList: string; +} +``` + +## Implementation Details + +### Container Component Pattern + +**SkipLink.tsx** (Container): +```typescript +export default function SkipLink(props: SkipLinkContainerProps): ReactElement { + const { linkText, mainContentId, listContentId, skipToPrefix, class: className, tabIndex, name } = props; + + return ( + + ); +} +``` + +**Benefits:** +- Clean separation of concerns +- Easier to test presentation logic +- Follows repository patterns (PopupMenu, TreeNode, LanguageSelector) + +### Portal Rendering Strategy + +**Why portals?** + +- Skip links must be the **first focusable element** on the page +- React portals allow rendering outside the widget's natural DOM position +- Inserted as first child of `#root` element during useEffect + +**Implementation:** +```typescript +const [linkRoot] = useState(() => document.createElement("div")); + +useEffect(() => { + const root = document.getElementById("root"); + 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(); // Cleanup on unmount + }; +}, [linkRoot]); +``` + +**Key improvements:** +- DOM manipulation in `useEffect` (not state initializer) prevents side effects during render +- Cleanup function removes portal element on unmount (prevents memory leaks) +- Error handling for missing root element + +### Focus Management + +**Target element focus logic:** + +1. Find target element by ID (or `
` tag if no ID provided) +2. Store original `tabindex` attribute value +3. Temporarily set `tabindex="-1"` to make element focusable +4. Call `.focus()` on the element +5. Clean up temporary `tabindex` on blur event (if it wasn't originally present) + +```typescript +function handleClick(event: MouseEvent, contentId?: string): void { + event.preventDefault(); + const targetId = contentId || props.mainContentId; + + // Find element by ID or fallback to
+ let main: HTMLElement; + 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(); + + // Cleanup on blur if tabindex wasn't originally present + 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."); + } +} +``` + +### Performance Optimizations + +**useMemo for Computed Strings:** +```typescript +// Main link text is memoized to prevent unnecessary recalculations +const mainLinkText = useMemo( + () => `${skipToPrefix} ${linkText}`, + [skipToPrefix, linkText] +); + +// List item text computed inline (lightweight operation) +{listContentId + .filter(item => item.contentIdInList.status === ValueStatus.Available && item.contentIdInList.value) + .map((item, index) => { + const contentId = item.contentIdInList.value!; + const linkText = `${skipToPrefix} ${item.LinkTextInList}`; + // ... + }) +} +``` + +**Key points:** +- Main link text uses `useMemo` as it's referenced in every render +- List items compute text inline (only rendered once per item) +- Filter before map to skip unavailable items early +``` + +### CSS Show/Hide Mechanism + +**Key CSS properties:** + +- **Container**: `transform: translateY(-120%)` hides off-screen +- **Focus trigger**: `:focus-within` pseudo-class shows entire container +- **Visual structure**: Flexbox column with border separators +- **Focus indicator**: `outline` with background highlight for keyboard accessibility + +```scss +.widget-skip-link-container { + transform: translateY(-120%); // Hidden by default + transition: transform 0.2s; + display: flex; + flex-direction: column; +} + +.widget-skip-link-container:focus-within { + transform: translateY(0); // Visible when any link focused +} + +.widget-skip-link { + border-bottom: 1px solid $grey; // Separator lines + text-decoration: none; + + &:last-child { + border-bottom: none; // No border on last item + } +} + +.widget-skip-link:focus { + outline: 2px solid var(--brand-secondary-light, $skp-brand-secondary-light); // Focus ring + outline-offset: -2px; // Inside element bounds + background-color: rgba(45, 186, 252, 0.1); // Subtle highlight +} +``` + +### List Item Rendering + +**Filter-then-map pattern:** +```typescript +{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} + + ); + }) +} +``` + +**Key implementation notes:** +- Main link text: `${props.skipToPrefix} ${props.linkText}` +- List link text: `${props.skipToPrefix} ${item.LinkTextInList}` +- All links use the same prefix for consistency +- Filter validates `ValueStatus.Available` and truthy value before rendering +- Non-null assertion (`!`) safe after filter check + +## Editor Preview + !item.contentIdInList.value) { + return null; + } + + const contentId = item.contentIdInList.value; + // Always use prefix + LinkTextInList (no fallback to contentId) + const linkText = `${props.skipToPrefix} ${item.LinkTextInList}`; + + return ( + handleClick(e, contentId)}> + {linkText} + + ); +})} +``` + +**Key implementation notes:** + +- Main link text: `${props.skipToPrefix} ${props.linkText}` +- List link text: `${props.skipToPrefix} ${item.LinkTextInList}` +- All links use the same prefix for consistency +- No fallback to contentId - LinkTextInList should always be provided + +## Editor Preview + +### Preview Rendering Strategy + +**Differences from runtime widget:** + +- **Always visible**: `transform: "none"` and `position: "relative"` +- **Uses actual CSS classes**: `.widget-skip-link` (not `.widget-skip-link-preview`) +- **Shows all configured links**: Main + all list items +- **Displays prefix + text**: Shows how links will appear to users + +```typescript +export const preview = (props: SkipLinkPreviewProps): ReactElement => { + return ( + + ); +}; +``` + +## Accessibility Considerations + +### WCAG Compliance + +- **2.4.1 Bypass Blocks (Level A)**: Primary purpose of this widget +- **2.1.1 Keyboard (Level A)**: Fully keyboard accessible +- **2.4.3 Focus Order (Level A)**: Inserted as first focusable element +- **2.4.7 Focus Visible (Level AA)**: Clear focus indicator with outline + +### Best Practices Implemented + +1. **First in tab order**: Portal ensures skip link is first +2. **Visible on focus**: Clear visual indicator when focused +3. **Descriptive text**: User-configurable link text +4. **Multiple targets**: Supports complex page structures +5. **Fallback behavior**: Searches for `
` if no ID specified + +## Common Use Cases + +### Single Main Content Skip + +```xml + + + + +``` + +### Multiple Skip Targets + +```xml + + + + + + navigation + {NavigationId} + + + search + {SearchId} + + + +``` + +### Translated Content (Dutch Example) + +```xml + + + + + navigatie + {NavigationId} + + + +``` + +## Technical Constraints + +### Mendix Platform Integration + +- **DynamicValue handling**: Must check `ValueStatus.Available` before using expression values +- **Portal limitations**: Requires `#root` element to exist (standard in Mendix apps) +- **CSS scoping**: Uses BEM-style naming to avoid conflicts + +### Browser Compatibility + +- **Modern browsers**: Requires CSS transforms and flexbox (IE11+) +- **Focus management**: Uses standard DOM focus API +- **Event listeners**: Uses `{ once: true }` option for cleanup (modern browsers) + +## Development Patterns + +### Adding New Properties + +1. Update `SkipLink.xml` with new property definition +2. Build widget to regenerate `SkipLinkProps.d.ts` +3. Use property in `SkipLink.tsx` component +4. Update `SkipLink.editorPreview.tsx` for Studio Pro preview +5. Add corresponding preview property handling + +### Testing Considerations + +- **Unit tests**: Mock Mendix `DynamicValue` and `ValueStatus` +- **Focus testing**: Use `waitFor` with RTL for async focus changes +- **Portal testing**: Query by text or role, not by container +- **Component separation**: Test container and presentation component separately +- **E2E tests**: Verify keyboard navigation and focus management + +### Component Architecture Best Practices + +**Why separate container and presentation:** +1. **Testability**: Presentation component can be tested in isolation +2. **Reusability**: Component logic separated from Mendix props +3. **Maintainability**: Clear separation of concerns +4. **Pattern consistency**: Follows repo standards (PopupMenu, TreeNode, LanguageSelector) + +**Container responsibilities:** +- Prop destructuring +- Passing props to presentation component +- Default export for widget loader + +**Presentation component responsibilities:** +- DOM manipulation (portal creation) +- User interactions (click handlers, focus management) +- Rendering logic +- Performance optimizations (useMemo, useCallback) + +## Error Handling + +### Graceful Degradation + +1. **Element not found**: Logs console error, does not crash +2. **No main element**: Logs helpful error message +3. **Missing root**: Logs error but continues initialization +4. **Unavailable expressions**: Skips rendering that list item + +### Console Errors + +```typescript +// Element ID not found +console.error(`Element with id: ${targetId} not found on page`); + +// No main element found +console.error("Could not find a main element on page and no mainContentId specified"); + +// No root element +console.error("No root element found on page"); +``` + +## Future Enhancement Considerations + +### Potential Improvements + +1. **ARIA live regions**: Announce skip link activation to screen readers +2. **Configurable position**: Allow customizing skip link placement +3. **Animation options**: User-configurable slide-in animation +4. **Multiple instances**: Handle multiple skip link widgets on same page +5. **Landmark detection**: Auto-discover landmarks (nav, aside, footer) + +### Extension Points + +- Custom styling via `class` prop +- Custom positioning via `style` prop +- Event handlers for skip link activation (future feature) + +## Summary for AI Agents + +**What this widget does:** +Provides accessible skip navigation for keyboard users by rendering hidden links that become visible on focus and programmatically move focus to specified page sections. + +**Key implementation details:** + +- **Container/Presentation pattern**: Clean separation following repo standards +- Uses React portals to render as first DOM element +- DOM manipulation in `useEffect` with proper cleanup +- CSS transforms hide/show links on focus (`:focus-within` container) +- Supports multiple skip targets via dynamic list +- Handles focus management with temporary tabindex modification +- All skip links use configurable prefix + link text pattern +- Performance optimized with `useMemo` for computed values +- Fully translatable and customizable text +- Handles focus management with temporary tabindex modification +- All skip links use configurable prefix + link text pattern +- Fully translatable and customizable text + +**When to use:** +Every Mendix page should have at least one skip link pointing to main content. Add additional skip links for complex pages with multiple content sections. + +**Configuration pattern:** + +- `skipToPrefix`: Global prefix for all links (e.g., "Skip to", "Ga naar") +- Main link: `prefix + linkText` (e.g., "Skip to" + "main content") +- List links: `prefix + LinkTextInList` (e.g., "Skip to" + "navigation") + +**Common modifications:** + +- Translating all text by changing `skipToPrefix` (e.g., "Ga naar" for Dutch) +- Adding more skip targets via `listContentId` array +- Changing individual link text via `linkText` and `LinkTextInList` +- Customizing styling (class, style props) diff --git a/packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.js b/packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.js index e36eee53ad..99a91eab74 100644 --- a/packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.js +++ b/packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.js @@ -15,7 +15,7 @@ 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)"); @@ -25,24 +25,24 @@ test.describe("SkipLink:", function () { // 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)") + 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 +50,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 +64,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/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} - + ); } else { return ( - - {props.linkText} - + ); } }; 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/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; } From ac4161b4f72558e022df129596286ea77a5b6d6d Mon Sep 17 00:00:00 2001 From: "hedwig.doets" Date: Thu, 9 Apr 2026 14:07:44 +0200 Subject: [PATCH 2/5] chore: remove accidentally added skills experiment --- .../skiplink-web/SKIPLINK-SKILL.md | 558 ------------------ 1 file changed, 558 deletions(-) delete mode 100644 packages/pluggableWidgets/skiplink-web/SKIPLINK-SKILL.md diff --git a/packages/pluggableWidgets/skiplink-web/SKIPLINK-SKILL.md b/packages/pluggableWidgets/skiplink-web/SKIPLINK-SKILL.md deleted file mode 100644 index 5359b20956..0000000000 --- a/packages/pluggableWidgets/skiplink-web/SKIPLINK-SKILL.md +++ /dev/null @@ -1,558 +0,0 @@ -# SkipLink Widget - AI Skill Document - -## Overview - -The SkipLink widget is a Mendix accessibility widget that provides keyboard users with the ability to skip directly to main content areas on a page, bypassing repetitive navigation elements. This is a critical WCAG 2.1 Level A accessibility requirement (Success Criterion 2.4.1 - Bypass Blocks). - -## Core Functionality - -### Purpose - -- Allows keyboard users to bypass repetitive content (headers, navigation menus, etc.) -- Improves accessibility for screen reader users -- Provides quick navigation to important page sections -- Supports WCAG 2.1 AA compliance - -### Behavior - -1. **Hidden by default**: Skip links are positioned off-screen using CSS transforms -2. **Visible on focus**: When a user tabs to a skip link, the entire container slides into view -3. **Multiple targets**: Supports one main skip target plus multiple additional targets via a list -4. **Focus management**: Programmatically sets focus to the target element and ensures it's keyboard-accessible - -## Architecture - -### Component Structure - -``` -SkipLink.tsx (Container Component) -└── SkipLinkComponent.tsx (Presentation Component) - ├── Portal Rendering (inserted as first child of #root) - ├── Container Div (.widget-skip-link-container) - │ ├── Main Skip Link (primary link) - │ └── Additional Skip Links (from listContentId array) -``` - -**Separation of Concerns:** -- `SkipLink.tsx`: Container component, handles prop destructuring and passes to presentation component -- `SkipLinkComponent.tsx`: Presentation component, handles DOM manipulation, rendering, and user interactions - -### Key Files - -- **SkipLink.tsx**: Container component (default export) -- **components/SkipLinkComponent.tsx**: Presentation component with portal logic -- **SkipLink.xml**: Widget configuration (properties schema) -- **SkipLink.scss**: Styling with show/hide behavior -- **SkipLink.editorPreview.tsx**: Studio Pro preview rendering -- **SkipLinkProps.d.ts**: Generated TypeScript types from XML - -## Configuration Properties - -### XML Schema Properties - -**Property Groups:** - -- **Main skip link**: Configuration for the primary skip link -- **Additional skip links**: List of additional navigation targets -- **Customization**: Global settings like prefix text - -**Individual Properties:** - -1. **linkText** (string, default: "main content") - - Text displayed for the main skip link (without prefix) - - Combined with `skipToPrefix` to form complete link text - - Example: "main content" becomes "Skip to main content" - - User-configurable and translatable - -2. **mainContentId** (string, optional) - - ID of the main content element to jump to - - If empty, widget searches for `
` tag - - Fallback behavior ensures accessibility - -3. **listContentId** (array of objects, optional) - - List of additional skip link targets - - Each item contains: - - `LinkTextInList`: Custom text for the skip link (without prefix) - - `contentIdInList`: Expression returning element ID (DynamicValue) - - All list items are rendered below the main skip link - -4. **skipToPrefix** (string, default: "Skip to") - - Prefix text used for ALL skip links (main + list items) - - Allows translation/customization for internationalization - - Applied to both main link and additional links - - Example: Change to "Ga naar" for Dutch translation - -### TypeScript Props Interface - -```typescript -export interface SkipLinkContainerProps { - name: string; - class: string; - style?: CSSProperties; - tabIndex?: number; - linkText: string; - mainContentId: string; - listContentId: ListContentIdType[]; - skipToPrefix: string; -} - -export interface ListContentIdType { - contentIdInList: DynamicValue; - LinkTextInList: string; -} -``` - -## Implementation Details - -### Container Component Pattern - -**SkipLink.tsx** (Container): -```typescript -export default function SkipLink(props: SkipLinkContainerProps): ReactElement { - const { linkText, mainContentId, listContentId, skipToPrefix, class: className, tabIndex, name } = props; - - return ( - - ); -} -``` - -**Benefits:** -- Clean separation of concerns -- Easier to test presentation logic -- Follows repository patterns (PopupMenu, TreeNode, LanguageSelector) - -### Portal Rendering Strategy - -**Why portals?** - -- Skip links must be the **first focusable element** on the page -- React portals allow rendering outside the widget's natural DOM position -- Inserted as first child of `#root` element during useEffect - -**Implementation:** -```typescript -const [linkRoot] = useState(() => document.createElement("div")); - -useEffect(() => { - const root = document.getElementById("root"); - 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(); // Cleanup on unmount - }; -}, [linkRoot]); -``` - -**Key improvements:** -- DOM manipulation in `useEffect` (not state initializer) prevents side effects during render -- Cleanup function removes portal element on unmount (prevents memory leaks) -- Error handling for missing root element - -### Focus Management - -**Target element focus logic:** - -1. Find target element by ID (or `
` tag if no ID provided) -2. Store original `tabindex` attribute value -3. Temporarily set `tabindex="-1"` to make element focusable -4. Call `.focus()` on the element -5. Clean up temporary `tabindex` on blur event (if it wasn't originally present) - -```typescript -function handleClick(event: MouseEvent, contentId?: string): void { - event.preventDefault(); - const targetId = contentId || props.mainContentId; - - // Find element by ID or fallback to
- let main: HTMLElement; - 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(); - - // Cleanup on blur if tabindex wasn't originally present - 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."); - } -} -``` - -### Performance Optimizations - -**useMemo for Computed Strings:** -```typescript -// Main link text is memoized to prevent unnecessary recalculations -const mainLinkText = useMemo( - () => `${skipToPrefix} ${linkText}`, - [skipToPrefix, linkText] -); - -// List item text computed inline (lightweight operation) -{listContentId - .filter(item => item.contentIdInList.status === ValueStatus.Available && item.contentIdInList.value) - .map((item, index) => { - const contentId = item.contentIdInList.value!; - const linkText = `${skipToPrefix} ${item.LinkTextInList}`; - // ... - }) -} -``` - -**Key points:** -- Main link text uses `useMemo` as it's referenced in every render -- List items compute text inline (only rendered once per item) -- Filter before map to skip unavailable items early -``` - -### CSS Show/Hide Mechanism - -**Key CSS properties:** - -- **Container**: `transform: translateY(-120%)` hides off-screen -- **Focus trigger**: `:focus-within` pseudo-class shows entire container -- **Visual structure**: Flexbox column with border separators -- **Focus indicator**: `outline` with background highlight for keyboard accessibility - -```scss -.widget-skip-link-container { - transform: translateY(-120%); // Hidden by default - transition: transform 0.2s; - display: flex; - flex-direction: column; -} - -.widget-skip-link-container:focus-within { - transform: translateY(0); // Visible when any link focused -} - -.widget-skip-link { - border-bottom: 1px solid $grey; // Separator lines - text-decoration: none; - - &:last-child { - border-bottom: none; // No border on last item - } -} - -.widget-skip-link:focus { - outline: 2px solid var(--brand-secondary-light, $skp-brand-secondary-light); // Focus ring - outline-offset: -2px; // Inside element bounds - background-color: rgba(45, 186, 252, 0.1); // Subtle highlight -} -``` - -### List Item Rendering - -**Filter-then-map pattern:** -```typescript -{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} - - ); - }) -} -``` - -**Key implementation notes:** -- Main link text: `${props.skipToPrefix} ${props.linkText}` -- List link text: `${props.skipToPrefix} ${item.LinkTextInList}` -- All links use the same prefix for consistency -- Filter validates `ValueStatus.Available` and truthy value before rendering -- Non-null assertion (`!`) safe after filter check - -## Editor Preview - !item.contentIdInList.value) { - return null; - } - - const contentId = item.contentIdInList.value; - // Always use prefix + LinkTextInList (no fallback to contentId) - const linkText = `${props.skipToPrefix} ${item.LinkTextInList}`; - - return ( - handleClick(e, contentId)}> - {linkText} - - ); -})} -``` - -**Key implementation notes:** - -- Main link text: `${props.skipToPrefix} ${props.linkText}` -- List link text: `${props.skipToPrefix} ${item.LinkTextInList}` -- All links use the same prefix for consistency -- No fallback to contentId - LinkTextInList should always be provided - -## Editor Preview - -### Preview Rendering Strategy - -**Differences from runtime widget:** - -- **Always visible**: `transform: "none"` and `position: "relative"` -- **Uses actual CSS classes**: `.widget-skip-link` (not `.widget-skip-link-preview`) -- **Shows all configured links**: Main + all list items -- **Displays prefix + text**: Shows how links will appear to users - -```typescript -export const preview = (props: SkipLinkPreviewProps): ReactElement => { - return ( - - ); -}; -``` - -## Accessibility Considerations - -### WCAG Compliance - -- **2.4.1 Bypass Blocks (Level A)**: Primary purpose of this widget -- **2.1.1 Keyboard (Level A)**: Fully keyboard accessible -- **2.4.3 Focus Order (Level A)**: Inserted as first focusable element -- **2.4.7 Focus Visible (Level AA)**: Clear focus indicator with outline - -### Best Practices Implemented - -1. **First in tab order**: Portal ensures skip link is first -2. **Visible on focus**: Clear visual indicator when focused -3. **Descriptive text**: User-configurable link text -4. **Multiple targets**: Supports complex page structures -5. **Fallback behavior**: Searches for `
` if no ID specified - -## Common Use Cases - -### Single Main Content Skip - -```xml - - - - -``` - -### Multiple Skip Targets - -```xml - - - - - - navigation - {NavigationId} - - - search - {SearchId} - - - -``` - -### Translated Content (Dutch Example) - -```xml - - - - - navigatie - {NavigationId} - - - -``` - -## Technical Constraints - -### Mendix Platform Integration - -- **DynamicValue handling**: Must check `ValueStatus.Available` before using expression values -- **Portal limitations**: Requires `#root` element to exist (standard in Mendix apps) -- **CSS scoping**: Uses BEM-style naming to avoid conflicts - -### Browser Compatibility - -- **Modern browsers**: Requires CSS transforms and flexbox (IE11+) -- **Focus management**: Uses standard DOM focus API -- **Event listeners**: Uses `{ once: true }` option for cleanup (modern browsers) - -## Development Patterns - -### Adding New Properties - -1. Update `SkipLink.xml` with new property definition -2. Build widget to regenerate `SkipLinkProps.d.ts` -3. Use property in `SkipLink.tsx` component -4. Update `SkipLink.editorPreview.tsx` for Studio Pro preview -5. Add corresponding preview property handling - -### Testing Considerations - -- **Unit tests**: Mock Mendix `DynamicValue` and `ValueStatus` -- **Focus testing**: Use `waitFor` with RTL for async focus changes -- **Portal testing**: Query by text or role, not by container -- **Component separation**: Test container and presentation component separately -- **E2E tests**: Verify keyboard navigation and focus management - -### Component Architecture Best Practices - -**Why separate container and presentation:** -1. **Testability**: Presentation component can be tested in isolation -2. **Reusability**: Component logic separated from Mendix props -3. **Maintainability**: Clear separation of concerns -4. **Pattern consistency**: Follows repo standards (PopupMenu, TreeNode, LanguageSelector) - -**Container responsibilities:** -- Prop destructuring -- Passing props to presentation component -- Default export for widget loader - -**Presentation component responsibilities:** -- DOM manipulation (portal creation) -- User interactions (click handlers, focus management) -- Rendering logic -- Performance optimizations (useMemo, useCallback) - -## Error Handling - -### Graceful Degradation - -1. **Element not found**: Logs console error, does not crash -2. **No main element**: Logs helpful error message -3. **Missing root**: Logs error but continues initialization -4. **Unavailable expressions**: Skips rendering that list item - -### Console Errors - -```typescript -// Element ID not found -console.error(`Element with id: ${targetId} not found on page`); - -// No main element found -console.error("Could not find a main element on page and no mainContentId specified"); - -// No root element -console.error("No root element found on page"); -``` - -## Future Enhancement Considerations - -### Potential Improvements - -1. **ARIA live regions**: Announce skip link activation to screen readers -2. **Configurable position**: Allow customizing skip link placement -3. **Animation options**: User-configurable slide-in animation -4. **Multiple instances**: Handle multiple skip link widgets on same page -5. **Landmark detection**: Auto-discover landmarks (nav, aside, footer) - -### Extension Points - -- Custom styling via `class` prop -- Custom positioning via `style` prop -- Event handlers for skip link activation (future feature) - -## Summary for AI Agents - -**What this widget does:** -Provides accessible skip navigation for keyboard users by rendering hidden links that become visible on focus and programmatically move focus to specified page sections. - -**Key implementation details:** - -- **Container/Presentation pattern**: Clean separation following repo standards -- Uses React portals to render as first DOM element -- DOM manipulation in `useEffect` with proper cleanup -- CSS transforms hide/show links on focus (`:focus-within` container) -- Supports multiple skip targets via dynamic list -- Handles focus management with temporary tabindex modification -- All skip links use configurable prefix + link text pattern -- Performance optimized with `useMemo` for computed values -- Fully translatable and customizable text -- Handles focus management with temporary tabindex modification -- All skip links use configurable prefix + link text pattern -- Fully translatable and customizable text - -**When to use:** -Every Mendix page should have at least one skip link pointing to main content. Add additional skip links for complex pages with multiple content sections. - -**Configuration pattern:** - -- `skipToPrefix`: Global prefix for all links (e.g., "Skip to", "Ga naar") -- Main link: `prefix + linkText` (e.g., "Skip to" + "main content") -- List links: `prefix + LinkTextInList` (e.g., "Skip to" + "navigation") - -**Common modifications:** - -- Translating all text by changing `skipToPrefix` (e.g., "Ga naar" for Dutch) -- Adding more skip targets via `listContentId` array -- Changing individual link text via `linkText` and `LinkTextInList` -- Customizing styling (class, style props) From b32f5d2e1f8b69706dc3c567a1717e71e7194299 Mon Sep 17 00:00:00 2001 From: "hedwig.doets" Date: Thu, 9 Apr 2026 14:07:58 +0200 Subject: [PATCH 3/5] chore: update tests --- .../skiplink-web/e2e/SkipLink.spec.js | 21 +- .../skiplink-focused-chromium-darwin.png | Bin 0 -> 2563 bytes .../src/__tests__/SkipLink.spec.tsx | 471 ++++++++++++++++-- .../__snapshots__/SkipLink.spec.tsx.snap | 61 --- 4 files changed, 446 insertions(+), 107 deletions(-) create mode 100644 packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.js-snapshots/skiplink-focused-chromium-darwin.png delete mode 100644 packages/pluggableWidgets/skiplink-web/src/__tests__/__snapshots__/SkipLink.spec.tsx.snap diff --git a/packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.js b/packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.js index 99a91eab74..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 () { @@ -16,9 +18,11 @@ test.describe("SkipLink:", function () { 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 }) => { @@ -27,9 +31,14 @@ test.describe("SkipLink:", function () { 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); + + // 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)"); }); 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 0000000000000000000000000000000000000000..075bf9406813e99b7ba2ca6264ba6e87d95c1398 GIT binary patch literal 2563 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 { let defaultProps: SkipLinkContainerProps; @@ -18,8 +20,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 +32,444 @@ 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`] = ` - -`; From a344baa245bb4b54196d9f3810d6122ebb2ab8d8 Mon Sep 17 00:00:00 2001 From: "hedwig.doets" Date: Thu, 9 Apr 2026 14:17:08 +0200 Subject: [PATCH 4/5] chore: fix code quality errors --- .../skiplink-web/src/__tests__/SkipLink.spec.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/pluggableWidgets/skiplink-web/src/__tests__/SkipLink.spec.tsx b/packages/pluggableWidgets/skiplink-web/src/__tests__/SkipLink.spec.tsx index e86c04e386..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,6 @@ import "@testing-library/jest-dom"; import { render, fireEvent, waitFor } from "@testing-library/react"; -import { DynamicValue } from "mendix"; -import { ValueStatus } from "mendix"; +import { DynamicValue, ValueStatus } from "mendix"; import { SkipLinkContainerProps, ListContentIdType } from "../../typings/SkipLinkProps"; import SkipLink from "../SkipLink"; @@ -205,7 +204,6 @@ describe("SkipLink", () => { 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 }); From 17b7bc2c4897f75d671fc970808e36b36dd622b1 Mon Sep 17 00:00:00 2001 From: "hedwig.doets" Date: Fri, 10 Apr 2026 12:44:39 +0200 Subject: [PATCH 5/5] chore: update screenshots --- .../skiplink-focused-chromium-darwin.png | Bin 2563 -> 2179 bytes .../skiplink-focused-chromium-linux.png | Bin 2569 -> 2179 bytes 2 files changed, 0 insertions(+), 0 deletions(-) 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 index 075bf9406813e99b7ba2ca6264ba6e87d95c1398..279ca41157668a4f28d2fa4f2dac7efacbbce58d 100644 GIT binary patch literal 2179 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