From edd39ee2c39869e3333510d4e8449e78d444dbda Mon Sep 17 00:00:00 2001 From: "hedwig.doets" Date: Thu, 16 Apr 2026 17:17:52 +0200 Subject: [PATCH 01/12] feat: improve keyboard navigation on popup menu --- .../popup-menu-web/src/components/Menu.tsx | 73 ++++++++++++++----- .../src/components/PopupMenu.tsx | 20 ++++- .../src/components/PopupTrigger.tsx | 2 + .../popup-menu-web/src/hooks/usePopup.ts | 22 +++++- 4 files changed, 93 insertions(+), 24 deletions(-) diff --git a/packages/pluggableWidgets/popup-menu-web/src/components/Menu.tsx b/packages/pluggableWidgets/popup-menu-web/src/components/Menu.tsx index 44bf1be653..a2a4289462 100644 --- a/packages/pluggableWidgets/popup-menu-web/src/components/Menu.tsx +++ b/packages/pluggableWidgets/popup-menu-web/src/components/Menu.tsx @@ -1,12 +1,14 @@ import { FloatingFocusManager, useMergeRefs } from "@floating-ui/react"; import classNames from "classnames"; import { ActionValue } from "mendix"; -import { forwardRef, ReactElement, RefObject } from "react"; +import { forwardRef, ReactElement, RefObject, MutableRefObject } from "react"; import { BasicItemsType, CustomItemsType, PopupMenuContainerProps } from "../../typings/PopupMenuProps"; import { usePopupContext } from "../hooks/usePopupContext"; export interface MenuProps extends PopupMenuContainerProps { onItemClick: (itemAction: ActionValue) => void; + listRef: MutableRefObject>; + activeIndex: number | null; } export const Menu = forwardRef((props: MenuProps, propRef: RefObject): ReactElement | null => { @@ -17,13 +19,14 @@ export const Menu = forwardRef((props: MenuProps, propRef: RefObject
    @@ -42,29 +45,47 @@ function checkVisibility(item: BasicItemsType | CustomItemsType): boolean { } function createMenuOptions( - props: PopupMenuContainerProps, - handleOnClickItem: (itemAction?: ActionValue) => void + props: MenuProps, + handleOnClickItem: (itemAction?: ActionValue) => void, + listRef: MutableRefObject>, + activeIndex: number | null ): ReactElement[] { + let itemIndex = 0; + if (!props.advancedMode) { return props.basicItems .filter(item => checkVisibility(item)) .map((item, index) => { if (item.itemType === "divider") { - return
  • ; + return
  • ; } else { const pickedStyle = item.styleClass !== "defaultStyle" ? "popupmenu-basic-item-" + item.styleClass.replace("Style", "") : ""; + const currentItemIndex = itemIndex++; + const isActive = currentItemIndex === activeIndex; return (
  • { + listRef.current[currentItemIndex] = el; + }} + role="menuitem" + tabIndex={isActive ? 0 : -1} className={classNames("popupmenu-basic-item", pickedStyle)} onClick={e => { e.preventDefault(); e.stopPropagation(); handleOnClickItem(item.action); }} + onKeyDown={e => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + handleOnClickItem(item.action); + } + }} > {item.caption?.value ?? ""}
  • @@ -74,18 +95,34 @@ function createMenuOptions( } else { return props.customItems .filter(item => checkVisibility(item)) - .map((item, index) => ( -
  • { - e.preventDefault(); - e.stopPropagation(); - handleOnClickItem(item.action); - }} - > - {item.content} -
  • - )); + .map((item, index) => { + const currentItemIndex = itemIndex++; + const isActive = currentItemIndex === activeIndex; + return ( +
  • { + listRef.current[currentItemIndex] = el; + }} + role="menuitem" + tabIndex={isActive ? 0 : -1} + className={"popupmenu-custom-item"} + onClick={e => { + e.preventDefault(); + e.stopPropagation(); + handleOnClickItem(item.action); + }} + onKeyDown={e => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + handleOnClickItem(item.action); + } + }} + > + {item.content} +
  • + ); + }); } } diff --git a/packages/pluggableWidgets/popup-menu-web/src/components/PopupMenu.tsx b/packages/pluggableWidgets/popup-menu-web/src/components/PopupMenu.tsx index 82534e4e3c..aa58d566c3 100644 --- a/packages/pluggableWidgets/popup-menu-web/src/components/PopupMenu.tsx +++ b/packages/pluggableWidgets/popup-menu-web/src/components/PopupMenu.tsx @@ -1,6 +1,6 @@ import classNames from "classnames"; import { ActionValue } from "mendix"; -import { ReactElement, useCallback, useEffect, useState } from "react"; +import { ReactElement, useCallback, useEffect, useState, useRef } from "react"; import { executeAction } from "@mendix/widget-plugin-platform/framework/execute-action"; import { Menu } from "./Menu"; import { PopupContext } from "./PopupContext"; @@ -10,17 +10,23 @@ import { usePopup } from "../hooks/usePopup"; export function PopupMenu(props: PopupMenuContainerProps): ReactElement { const [visibility, setVisibility] = useState(props.menuToggle); + const [activeIndex, setActiveIndex] = useState(null); + const listRef = useRef>([]); const open = visibility; const popup = usePopup({ open, onOpenChange: setVisibility, placement: props.position, trigger: props.trigger, - clippingStrategy: props.clippingStrategy + clippingStrategy: props.clippingStrategy, + listRef, + activeIndex, + onNavigate: setActiveIndex }); const handleOnClickItem = useCallback((itemAction?: ActionValue): void => { setVisibility(false); + setActiveIndex(null); executeAction(itemAction); }, []); @@ -28,11 +34,19 @@ export function PopupMenu(props: PopupMenuContainerProps): ReactElement { setVisibility(props.menuToggle); }, [props.menuToggle]); + useEffect(() => { + if (visibility) { + setActiveIndex(0); + } else { + setActiveIndex(null); + } + }, [visibility]); + return (
    {props.menuTrigger} - +
    ); diff --git a/packages/pluggableWidgets/popup-menu-web/src/components/PopupTrigger.tsx b/packages/pluggableWidgets/popup-menu-web/src/components/PopupTrigger.tsx index fefc4aa7c5..59e7540406 100644 --- a/packages/pluggableWidgets/popup-menu-web/src/components/PopupTrigger.tsx +++ b/packages/pluggableWidgets/popup-menu-web/src/components/PopupTrigger.tsx @@ -13,6 +13,8 @@ export const PopupTrigger = forwardRef( className={"popupmenu-trigger"} ref={ref} data-state={open ? "open" : "closed"} + aria-haspopup="menu" + aria-expanded={open} {...getReferenceProps?.({ onClick: e => { e.stopPropagation(); diff --git a/packages/pluggableWidgets/popup-menu-web/src/hooks/usePopup.ts b/packages/pluggableWidgets/popup-menu-web/src/hooks/usePopup.ts index dfcd02f25c..f69a2662de 100644 --- a/packages/pluggableWidgets/popup-menu-web/src/hooks/usePopup.ts +++ b/packages/pluggableWidgets/popup-menu-web/src/hooks/usePopup.ts @@ -12,8 +12,10 @@ import { useHover, useInteractions, UseInteractionsReturn, + useListNavigation, useRole } from "@floating-ui/react"; +import { MutableRefObject, useRef } from "react"; import { ClippingStrategyEnum, TriggerEnum } from "../../typings/PopupMenuProps"; interface PopupOptions { @@ -23,6 +25,9 @@ interface PopupOptions { onOpenChange?: (open: boolean) => void; clippingStrategy?: ClippingStrategyEnum; trigger?: TriggerEnum; + listRef?: MutableRefObject>; + activeIndex?: number | null; + onNavigate?: (index: number | null) => void; } type FloatingReturn = Pick; @@ -40,8 +45,13 @@ export function usePopup({ open, onOpenChange: setOpen, trigger, - clippingStrategy + clippingStrategy, + listRef, + activeIndex, + onNavigate }: PopupOptions = {}): UsePopupReturn { + const fallbackListRef = useRef>([]); + const { context, floatingStyles, refs } = useFloating({ middleware: [offset(5), flip(), shift()], onOpenChange: setOpen, @@ -52,11 +62,17 @@ export function usePopup({ }); const dismiss = useDismiss(context); - const role = useRole(context); + const role = useRole(context, { role: "menu" }); const click = useClick(context, { enabled: trigger === "onclick" }); const hover = useHover(context, { enabled: trigger === "onhover", handleClose: safePolygon() }); + const listNav = useListNavigation(context, { + listRef: listRef ?? fallbackListRef, + activeIndex: activeIndex ?? null, + onNavigate: onNavigate ?? (() => {}), + loop: true + }); - const { getFloatingProps, getReferenceProps } = useInteractions([dismiss, role, click, hover]); + const { getFloatingProps, getReferenceProps } = useInteractions([dismiss, role, click, hover, listNav]); return { context, From 94e10d3ed46269ce37b082291abbefe83c9aabf3 Mon Sep 17 00:00:00 2001 From: "hedwig.doets" Date: Tue, 21 Apr 2026 13:18:27 +0200 Subject: [PATCH 02/12] fix(popup-menu-web): apply ARIA attributes directly to trigger element --- .../popup-menu-web/src/components/Menu.tsx | 4 +-- .../src/components/PopupTrigger.tsx | 34 +++++++++---------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/packages/pluggableWidgets/popup-menu-web/src/components/Menu.tsx b/packages/pluggableWidgets/popup-menu-web/src/components/Menu.tsx index a2a4289462..72d9bdee06 100644 --- a/packages/pluggableWidgets/popup-menu-web/src/components/Menu.tsx +++ b/packages/pluggableWidgets/popup-menu-web/src/components/Menu.tsx @@ -12,10 +12,10 @@ export interface MenuProps extends PopupMenuContainerProps { } export const Menu = forwardRef((props: MenuProps, propRef: RefObject): ReactElement | null => { - const { context: floatingContext, floatingStyles, getFloatingProps, modal, refs } = usePopupContext(); + const { context: floatingContext, floatingStyles, getFloatingProps, modal, refs, open } = usePopupContext(); const ref = useMergeRefs([refs.setFloating, propRef]); - if (!floatingContext.open) { + if (!open) { return null; } diff --git a/packages/pluggableWidgets/popup-menu-web/src/components/PopupTrigger.tsx b/packages/pluggableWidgets/popup-menu-web/src/components/PopupTrigger.tsx index 59e7540406..62f1124bc4 100644 --- a/packages/pluggableWidgets/popup-menu-web/src/components/PopupTrigger.tsx +++ b/packages/pluggableWidgets/popup-menu-web/src/components/PopupTrigger.tsx @@ -1,28 +1,26 @@ import { useMergeRefs } from "@floating-ui/react"; -import { forwardRef, PropsWithChildren, ReactElement, RefObject } from "react"; +import { cloneElement, forwardRef, PropsWithChildren, ReactElement, RefObject } from "react"; import { usePopupContext } from "../hooks/usePopupContext"; export const PopupTrigger = forwardRef( ({ children }: PropsWithChildren, propRef: RefObject): ReactElement => { const { getReferenceProps, open, refs } = usePopupContext(); - const childrenRef = (children as any).ref; + const childElement = children as ReactElement; + const childrenRef = (childElement as any).ref; const ref = useMergeRefs([refs.setReference, propRef, childrenRef]); - return ( -
    { - e.stopPropagation(); - } - })} - > - {children} -
    - ); + // Clone the child element and apply ARIA attributes and ref directly to it + // This ensures screen readers see the attributes on the actual button element + return cloneElement(childElement, { + ref, + "aria-haspopup": "menu", + "aria-expanded": open, + "data-state": open ? "open" : "closed", + ...getReferenceProps?.({ + onClick: e => { + e.stopPropagation(); + } + }) + } as any); } ); From a195ebbdfdbfd58a8e6040e40666387ea82f389a Mon Sep 17 00:00:00 2001 From: "hedwig.doets" Date: Tue, 21 Apr 2026 13:22:51 +0200 Subject: [PATCH 03/12] chore: update tests for ARIA attribute changes --- .../src/__tests__/Menu.spec.tsx | 120 +++++++++++++++++- .../src/__tests__/MenuWithContext.tsx | 5 +- .../src/__tests__/PopupMenu.spec.tsx | 23 ++++ .../__snapshots__/Menu.spec.tsx.snap | 9 +- .../__snapshots__/PopupMenu.spec.tsx.snap | 19 +-- .../__snapshots__/PopupTrigger.spec.tsx.snap | 12 +- 6 files changed, 169 insertions(+), 19 deletions(-) diff --git a/packages/pluggableWidgets/popup-menu-web/src/__tests__/Menu.spec.tsx b/packages/pluggableWidgets/popup-menu-web/src/__tests__/Menu.spec.tsx index 6cf6bcbfa9..a2e46e44ed 100644 --- a/packages/pluggableWidgets/popup-menu-web/src/__tests__/Menu.spec.tsx +++ b/packages/pluggableWidgets/popup-menu-web/src/__tests__/Menu.spec.tsx @@ -41,7 +41,9 @@ describe("Menu", () => { ], customItems: [customItemProps], onItemClick: jest.fn(), - clippingStrategy: "absolute" + clippingStrategy: "absolute", + listRef: { current: [] }, + activeIndex: 0 }; it("renders menu", () => { @@ -133,6 +135,32 @@ describe("Menu", () => { expect(container.querySelectorAll(".popupmenu-basic-item-danger")).toHaveLength(1); }); + + it("triggers action on Enter key press", async () => { + basicItemProps.action = actionValue(); + basicItemProps.styleClass = "defaultStyle"; + const popupMenu = createPopupMenu(defaultProps); + const { container } = popupMenu; + const firstItem = container.querySelector(".popupmenu-basic-item"); + + expect(firstItem).not.toBeNull(); + await fireEvent.keyDown(firstItem!, { key: "Enter" }); + + expect(defaultProps.onItemClick).toHaveBeenCalled(); + }); + + it("triggers action on Space key press", async () => { + basicItemProps.action = actionValue(); + basicItemProps.styleClass = "defaultStyle"; + const popupMenu = createPopupMenu(defaultProps); + const { container } = popupMenu; + const firstItem = container.querySelector(".popupmenu-basic-item"); + + expect(firstItem).not.toBeNull(); + await fireEvent.keyDown(firstItem!, { key: " " }); + + expect(defaultProps.onItemClick).toHaveBeenCalled(); + }); }); describe("with custom items", () => { @@ -173,4 +201,94 @@ describe("Menu", () => { expect(defaultProps.onItemClick).toHaveBeenCalledTimes(1); }); }); + + describe("keyboard accessibility - ARIA attributes", () => { + it("renders first item with tabindex 0 when activeIndex is 0", () => { + const freshBasicItem: BasicItemsType = { + itemType: "item", + caption: dynamicValue("Caption"), + styleClass: "defaultStyle" + }; + const testProps = { + ...defaultProps, + advancedMode: false, // Explicitly set to false to avoid pollution from previous tests + basicItems: [freshBasicItem], + activeIndex: 0 + }; + const popupMenu = createPopupMenu(testProps); + const { container } = popupMenu; + popupMenu.rerender(); + + const firstItem = container.querySelector(".popupmenu-basic-item"); + + expect(firstItem).toHaveAttribute("tabindex", "0"); + }); + + it("renders second item with tabindex 0 when activeIndex is 1", () => { + const testItem1: BasicItemsType = { + itemType: "item", + caption: dynamicValue("First"), + styleClass: "defaultStyle" + }; + const testItem2: BasicItemsType = { + itemType: "item", + caption: dynamicValue("Second"), + styleClass: "defaultStyle" + }; + const testProps = { + ...defaultProps, + advancedMode: false, // Explicitly set to false + activeIndex: 1, + basicItems: [testItem1, testItem2] + }; + const popupMenu = createPopupMenu(testProps); + const { container } = popupMenu; + popupMenu.rerender(); + + const items = container.querySelectorAll(".popupmenu-basic-item"); + + expect(items[0]).toHaveAttribute("tabindex", "-1"); + expect(items[1]).toHaveAttribute("tabindex", "0"); + }); + + it("renders basic menu items with role menuitem", () => { + const freshBasicItem: BasicItemsType = { + itemType: "item", + caption: dynamicValue("Caption"), + styleClass: "defaultStyle" + }; + const testProps = { + ...defaultProps, + advancedMode: false, // Explicitly set to false + basicItems: [freshBasicItem] + }; + const popupMenu = createPopupMenu(testProps); + const { container } = popupMenu; + popupMenu.rerender(); + + const menuItems = container.querySelectorAll(".popupmenu-basic-item"); + + menuItems.forEach(item => { + expect(item).toHaveAttribute("role", "menuitem"); + }); + }); + + it("renders custom menu items with role menuitem", () => { + const freshCustomItem: CustomItemsType = { content: createElement("div", null, null) }; + const testProps = { + ...defaultProps, + advancedMode: true, + customItems: [freshCustomItem] + }; + const popupMenu = createPopupMenu(testProps); + const { container } = popupMenu; + popupMenu.rerender(); + + const menuItems = container.querySelectorAll(".popupmenu-custom-item"); + + menuItems.forEach(item => { + expect(item).toHaveAttribute("role", "menuitem"); + }); + }); + }); }); diff --git a/packages/pluggableWidgets/popup-menu-web/src/__tests__/MenuWithContext.tsx b/packages/pluggableWidgets/popup-menu-web/src/__tests__/MenuWithContext.tsx index 2e56ff6f5d..fb8dc34315 100644 --- a/packages/pluggableWidgets/popup-menu-web/src/__tests__/MenuWithContext.tsx +++ b/packages/pluggableWidgets/popup-menu-web/src/__tests__/MenuWithContext.tsx @@ -9,7 +9,10 @@ export function MenuWithContext(props: MenuProps): ReactElement { onOpenChange: jest.fn(), placement: props.position, trigger: props.trigger, - clippingStrategy: props.clippingStrategy + clippingStrategy: props.clippingStrategy, + listRef: props.listRef, + activeIndex: props.activeIndex, + onNavigate: jest.fn() }); return ( diff --git a/packages/pluggableWidgets/popup-menu-web/src/__tests__/PopupMenu.spec.tsx b/packages/pluggableWidgets/popup-menu-web/src/__tests__/PopupMenu.spec.tsx index 9c4169f557..ea93c3fe56 100644 --- a/packages/pluggableWidgets/popup-menu-web/src/__tests__/PopupMenu.spec.tsx +++ b/packages/pluggableWidgets/popup-menu-web/src/__tests__/PopupMenu.spec.tsx @@ -62,4 +62,27 @@ describe("Popup Menu", () => { expect(container.querySelectorAll(".popupmenu-custom-item")).toHaveLength(0); }); }); + + describe("keyboard accessibility", () => { + it("renders trigger with aria-haspopup menu", () => { + const { getByText } = createPopupMenu(defaultProps); + const trigger = getByText("Trigger"); + + expect(trigger).toHaveAttribute("aria-haspopup", "menu"); + }); + + it("renders trigger with aria-expanded true when menu is open", () => { + const { getByText } = createPopupMenu({ ...defaultProps, menuToggle: true }); + const trigger = getByText("Trigger"); + + expect(trigger).toHaveAttribute("aria-expanded", "true"); + }); + + it("renders trigger with aria-expanded false when menu is closed", () => { + const { getByText } = createPopupMenu({ ...defaultProps, menuToggle: false }); + const trigger = getByText("Trigger"); + + expect(trigger).toHaveAttribute("aria-expanded", "false"); + }); + }); }); diff --git a/packages/pluggableWidgets/popup-menu-web/src/__tests__/__snapshots__/Menu.spec.tsx.snap b/packages/pluggableWidgets/popup-menu-web/src/__tests__/__snapshots__/Menu.spec.tsx.snap index d2eb16172f..97c332c96d 100644 --- a/packages/pluggableWidgets/popup-menu-web/src/__tests__/__snapshots__/Menu.spec.tsx.snap +++ b/packages/pluggableWidgets/popup-menu-web/src/__tests__/__snapshots__/Menu.spec.tsx.snap @@ -15,20 +15,25 @@ exports[`Menu renders menu 1`] = ` class="widget-popupmenu-root" >
diff --git a/packages/pluggableWidgets/popup-menu-web/src/__tests__/__snapshots__/PopupMenu.spec.tsx.snap b/packages/pluggableWidgets/popup-menu-web/src/__tests__/__snapshots__/PopupMenu.spec.tsx.snap index de316ba854..9635b94475 100644 --- a/packages/pluggableWidgets/popup-menu-web/src/__tests__/__snapshots__/PopupMenu.spec.tsx.snap +++ b/packages/pluggableWidgets/popup-menu-web/src/__tests__/__snapshots__/PopupMenu.spec.tsx.snap @@ -5,19 +5,17 @@ exports[`Popup Menu renders popup menu 1`] = `
- + Trigger +
diff --git a/packages/pluggableWidgets/popup-menu-web/src/__tests__/__snapshots__/PopupTrigger.spec.tsx.snap b/packages/pluggableWidgets/popup-menu-web/src/__tests__/__snapshots__/PopupTrigger.spec.tsx.snap index c01aa8c1ef..1f57f4ad5d 100644 --- a/packages/pluggableWidgets/popup-menu-web/src/__tests__/__snapshots__/PopupTrigger.spec.tsx.snap +++ b/packages/pluggableWidgets/popup-menu-web/src/__tests__/__snapshots__/PopupTrigger.spec.tsx.snap @@ -2,16 +2,14 @@ exports[`Popup Trigger renders popup trigger 1`] = ` -
- -
+ Trigger +
`; From 6a650bed28854e242c332d933cead51e8ba7e886 Mon Sep 17 00:00:00 2001 From: "hedwig.doets" Date: Tue, 21 Apr 2026 13:27:14 +0200 Subject: [PATCH 04/12] chore: add agents.md file --- .../pluggableWidgets/popup-menu-web/AGENTS.md | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 packages/pluggableWidgets/popup-menu-web/AGENTS.md diff --git a/packages/pluggableWidgets/popup-menu-web/AGENTS.md b/packages/pluggableWidgets/popup-menu-web/AGENTS.md new file mode 100644 index 0000000000..7e2eff660a --- /dev/null +++ b/packages/pluggableWidgets/popup-menu-web/AGENTS.md @@ -0,0 +1,73 @@ +# Popup Menu Widget - Agent Context + +Dropdown/popup menu widget with keyboard navigation and accessibility. Uses Floating UI for positioning. + +## Architecture + +**Stack**: React + TypeScript + Floating UI + SCSS +**Modes**: Basic (text items) or Advanced (custom content) + +**Component hierarchy**: + +``` +PopupMenu → PopupContext.Provider + ├── PopupTrigger (uses cloneElement) + └── Menu (FloatingFocusManager + roving tabindex) +``` + +## Critical Implementation Details + +### Why `cloneElement` in PopupTrigger + +ARIA attributes (`aria-haspopup`, `aria-expanded`) must be on the **actual trigger element**, not a wrapper div. + +```tsx +// ✅ cloneElement applies props directly to child +return cloneElement(childElement, { + ref, + "aria-haspopup": "menu", + "aria-expanded": open, + ...getReferenceProps?.({ onClick: ... }) +} as any); +``` + +**Why `as any`**: React 19 types don't recognize `ref` in cloneElement props object, even though it's supported. TypeScript also can't verify props for unknown child components. + +### Roving Tabindex Pattern + +Only **one** menu item has `tabindex="0"` (the active one), others have `tabindex="-1"`. This enables Arrow Up/Down navigation via `useListNavigation`. + +## Breaking Change Alert (AT-174) + +**Removed**: `.popupmenu-trigger` wrapper div +**Impact**: Tests using `container.querySelector(".popupmenu-trigger")` will fail +**Fix**: Use `getByText("Trigger")` or query the actual trigger element + +## Key Files + +- **PopupMenu.tsx**: State management (open, activeIndex), context provider +- **PopupTrigger.tsx**: cloneElement pattern, ref merging +- **Menu.tsx**: Roving tabindex, keyboard handlers (Enter/Space) +- **usePopup.ts**: Floating UI integration (click, hover, dismiss, listNavigation) + +## Testing Gotchas + +1. **Test context wrappers**: `MenuWithContext.tsx` provides PopupContext for isolated Menu tests +2. **Snapshots changed**: No wrapper div around trigger element +3. **ARIA assertions**: Test attributes on the trigger element directly + +## Common Issues + +**"Trigger is null" in tests**: No `.popupmenu-trigger` class anymore, query actual element +**TypeScript ref errors**: Add `as any` to cloneElement props object +**Keyboard nav broken**: Check `listRef` population and `activeIndex` state updates + +## References + +- [Floating UI Docs](https://floating-ui.com/) +- [ARIA Menu Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menu/) + +--- + +**Last Updated**: 2026-04-21 +**Last Major Change**: Keyboard accessibility (AT-174) - removed trigger wrapper, added roving tabindex From 1ce060ab6223af0b6b2f3ba32042968b75950b81 Mon Sep 17 00:00:00 2001 From: "hedwig.doets" Date: Tue, 21 Apr 2026 13:32:21 +0200 Subject: [PATCH 05/12] chore: bump version, update changelog --- packages/pluggableWidgets/popup-menu-web/CHANGELOG.md | 7 +++++++ packages/pluggableWidgets/popup-menu-web/package.json | 2 +- packages/pluggableWidgets/popup-menu-web/src/package.xml | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/pluggableWidgets/popup-menu-web/CHANGELOG.md b/packages/pluggableWidgets/popup-menu-web/CHANGELOG.md index 6a87686255..3f2f73b09d 100644 --- a/packages/pluggableWidgets/popup-menu-web/CHANGELOG.md +++ b/packages/pluggableWidgets/popup-menu-web/CHANGELOG.md @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +## [4.1.1] - 2026-04-21 + +### Fixed + +- We improved keyboard navigation by implementing roving tabindex pattern for menu items, allowing arrow key navigation through items. +- We fixed accessibility by applying ARIA attributes directly to the trigger element instead of a wrapper div, improving screen reader compatibility. + ## [4.1.0] - 2026-04-13 ### Fixed diff --git a/packages/pluggableWidgets/popup-menu-web/package.json b/packages/pluggableWidgets/popup-menu-web/package.json index 37d943d729..c0b8bfa297 100644 --- a/packages/pluggableWidgets/popup-menu-web/package.json +++ b/packages/pluggableWidgets/popup-menu-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/popup-menu-web", "widgetName": "PopupMenu", - "version": "4.1.0", + "version": "4.1.1", "description": "Popup Menu widget for displaying a list of actions in a popup.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", diff --git a/packages/pluggableWidgets/popup-menu-web/src/package.xml b/packages/pluggableWidgets/popup-menu-web/src/package.xml index 9f95ace297..f285fb77dd 100644 --- a/packages/pluggableWidgets/popup-menu-web/src/package.xml +++ b/packages/pluggableWidgets/popup-menu-web/src/package.xml @@ -1,6 +1,6 @@ - + From 3a522fab03fb86c0315c0f9cb87fb37d10261352 Mon Sep 17 00:00:00 2001 From: "hedwig.doets" Date: Tue, 21 Apr 2026 13:47:26 +0200 Subject: [PATCH 06/12] fix: revert breaking change to popup menu wrapper --- .../__snapshots__/PopupMenu.spec.tsx.snap | 12 +++++-- .../__snapshots__/PopupTrigger.spec.tsx.snap | 12 +++++-- .../src/components/PopupTrigger.tsx | 32 ++++++++++++------- 3 files changed, 39 insertions(+), 17 deletions(-) diff --git a/packages/pluggableWidgets/popup-menu-web/src/__tests__/__snapshots__/PopupMenu.spec.tsx.snap b/packages/pluggableWidgets/popup-menu-web/src/__tests__/__snapshots__/PopupMenu.spec.tsx.snap index 9635b94475..14a1da10ca 100644 --- a/packages/pluggableWidgets/popup-menu-web/src/__tests__/__snapshots__/PopupMenu.spec.tsx.snap +++ b/packages/pluggableWidgets/popup-menu-web/src/__tests__/__snapshots__/PopupMenu.spec.tsx.snap @@ -5,17 +5,23 @@ exports[`Popup Menu renders popup menu 1`] = `
- + +