diff --git a/packages/pluggableWidgets/popup-menu-web/AGENTS.md b/packages/pluggableWidgets/popup-menu-web/AGENTS.md new file mode 100644 index 0000000000..ef802b728b --- /dev/null +++ b/packages/pluggableWidgets/popup-menu-web/AGENTS.md @@ -0,0 +1,80 @@ +# 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 + +### ARIA Attributes via DOM Manipulation + +ARIA attributes (`aria-haspopup`, `aria-expanded`) must be on the **actual interactive element** (button/link) for screen reader compatibility, not the wrapper div. + +**Problem**: Mendix widgets don't forward arbitrary props, so we can't pass ARIA attributes through React props. + +**Solution**: Use `useEffect` + DOM query to find and enhance the interactive element: + +```tsx +useEffect(() => { + const interactiveElement = innerRef.current?.querySelector("button, a, [role='button'], input"); + + if (interactiveElement) { + interactiveElement.setAttribute("aria-haspopup", "menu"); + interactiveElement.setAttribute("aria-expanded", String(open)); + } +}, [open]); +``` + +**Why wrapper remains**: Backwards compatibility - `.popupmenu-trigger` class preserved for custom styling and Floating UI refs. +**Why DOM manipulation**: More robust than `cloneElement` - works with any Mendix widget child structure. + +### 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`. + +## Implementation Notes (AT-174) + +**Wrapper preserved**: `.popupmenu-trigger` wrapper div kept for backwards compatibility +**ARIA placement**: Applied to first interactive child element (button/link) via DOM manipulation +**No breaking changes**: Custom styles targeting `.popupmenu-trigger` continue to work +**Floating UI integration**: `useListNavigation`, `useRole`, `useDismiss` for full keyboard support + +## Key Files + +- **PopupMenu.tsx**: State management (open, activeIndex), context provider +- **PopupTrigger.tsx**: DOM manipulation for ARIA attributes, ref merging +- **Menu.tsx**: Roving tabindex, keyboard handlers (Enter/Space), `getItemProps` integration +- **usePopup.ts**: Floating UI integration (click, hover, dismiss, listNavigation) + +## Testing Gotchas + +1. **Test context wrappers**: `MenuWithContext.tsx` provides PopupContext for isolated Menu tests +2. **ARIA assertions**: Attributes are on the button element inside `.popupmenu-trigger`, not the wrapper +3. **E2E keyboard tests**: Use `page.keyboard.press()` for arrow navigation, check tabindex changes + +## Common Issues + +**ARIA attributes not applied**: Check that child widget renders an interactive element (button/a/input) +**Keyboard nav broken**: Verify `listRef` is populated and `activeIndex` updates on arrow keys +**Focus not visible**: Ensure `FloatingFocusManager` wraps menu and items have proper tabindex + +## References + +- [Floating UI Docs](https://floating-ui.com/) +- [ARIA Menu Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menu/) + +--- + +**Last Updated**: 2026-04-23 +**Last Major Change**: Keyboard accessibility (AT-174) - DOM-based ARIA attributes, roving tabindex, full arrow key navigation diff --git a/packages/pluggableWidgets/popup-menu-web/CHANGELOG.md b/packages/pluggableWidgets/popup-menu-web/CHANGELOG.md index 6a87686255..64bc7e5f7c 100644 --- a/packages/pluggableWidgets/popup-menu-web/CHANGELOG.md +++ b/packages/pluggableWidgets/popup-menu-web/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### 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/e2e/PopupMenu.spec.js b/packages/pluggableWidgets/popup-menu-web/e2e/PopupMenu.spec.js index a4b1778998..b72d237bd8 100644 --- a/packages/pluggableWidgets/popup-menu-web/e2e/PopupMenu.spec.js +++ b/packages/pluggableWidgets/popup-menu-web/e2e/PopupMenu.spec.js @@ -98,6 +98,74 @@ test.describe("Popup-menu-web", () => { await expect(modalDialog).toBeVisible(); await expect(modalDialog).toContainText("hello"); }); + + test("navigates menu items with arrow keys", async ({ page }) => { + // Open the menu + const button10 = await page.locator(".mx-name-actionButton10"); + await expect(button10).toBeVisible(); + await button10.click(); + + const popupPortal = await page.locator(".mx-name-pop_upMenu18"); + await expect(popupPortal).toBeVisible(); + + const menuItems = await popupPortal.locator(".popupmenu-basic-item").all(); + expect(menuItems.length).toBeGreaterThan(0); + + // First item should have focus initially (tabindex="0") + await expect(menuItems[0]).toHaveAttribute("tabindex", "0"); + + // Press ArrowDown to move to next item + await page.keyboard.press("ArrowDown"); + await expect(menuItems[0]).toHaveAttribute("tabindex", "-1"); + await expect(menuItems[1]).toHaveAttribute("tabindex", "0"); + + // Press ArrowDown again + await page.keyboard.press("ArrowDown"); + await expect(menuItems[1]).toHaveAttribute("tabindex", "-1"); + await expect(menuItems[2]).toHaveAttribute("tabindex", "0"); + + // Press ArrowUp to go back + await page.keyboard.press("ArrowUp"); + await expect(menuItems[2]).toHaveAttribute("tabindex", "-1"); + await expect(menuItems[1]).toHaveAttribute("tabindex", "0"); + + // Test loop behavior: navigate to last item then press ArrowDown to wrap to first + const lastIndex = menuItems.length - 1; + for (let i = 1; i < lastIndex; i++) { + await page.keyboard.press("ArrowDown"); + } + await expect(menuItems[lastIndex]).toHaveAttribute("tabindex", "0"); + + // Press ArrowDown to wrap to first item + await page.keyboard.press("ArrowDown"); + await expect(menuItems[lastIndex]).toHaveAttribute("tabindex", "-1"); + await expect(menuItems[0]).toHaveAttribute("tabindex", "0"); + + // Test reverse loop: press ArrowUp to wrap to last item + await page.keyboard.press("ArrowUp"); + await expect(menuItems[0]).toHaveAttribute("tabindex", "-1"); + await expect(menuItems[lastIndex]).toHaveAttribute("tabindex", "0"); + }); + + test("activates menu item with Enter key after arrow navigation", async ({ page }) => { + // Open the menu + const button10 = await page.locator(".mx-name-actionButton10"); + await button10.click(); + + const popupPortal = await page.locator(".mx-name-pop_upMenu18"); + await expect(popupPortal).toBeVisible(); + + // Stay on first item (which we know triggers a modal based on existing test) + const firstItem = await popupPortal.locator(".popupmenu-basic-item").first(); + + // Activate with Enter + await page.keyboard.press("Enter"); + + // Verify action was triggered - first item shows "hello" modal + const modalDialog = await page.locator(".modal-dialog"); + await expect(modalDialog).toBeVisible(); + await expect(modalDialog).toContainText("hello"); + }); }); test.describe("using custom option", () => { test.beforeEach(async ({ page }) => { 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..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 @@ -8,13 +8,17 @@ exports[`Popup Menu renders popup menu 1`] = ` @@ -31,20 +35,25 @@ exports[`Popup Menu renders popup menu 1`] = ` class="widget-popupmenu-root" > 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..f5a7cbacbf 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 @@ -5,11 +5,15 @@ exports[`Popup Trigger renders popup trigger 1`] = `
-
diff --git a/packages/pluggableWidgets/popup-menu-web/src/components/Menu.tsx b/packages/pluggableWidgets/popup-menu-web/src/components/Menu.tsx index 44bf1be653..0d0d8a7095 100644 --- a/packages/pluggableWidgets/popup-menu-web/src/components/Menu.tsx +++ b/packages/pluggableWidgets/popup-menu-web/src/components/Menu.tsx @@ -1,29 +1,42 @@ -import { FloatingFocusManager, useMergeRefs } from "@floating-ui/react"; +import { FloatingFocusManager, useMergeRefs, useInteractions } 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"; +type GetItemProps = ReturnType["getItemProps"]; + export interface MenuProps extends PopupMenuContainerProps { onItemClick: (itemAction: ActionValue) => void; + listRef: MutableRefObject>; + activeIndex: number | null; } export const Menu = forwardRef((props: MenuProps, propRef: RefObject): ReactElement | null => { - const { context: floatingContext, floatingStyles, getFloatingProps, modal, refs } = usePopupContext(); + const { + context: floatingContext, + floatingStyles, + getFloatingProps, + getItemProps, + modal, + refs, + open + } = usePopupContext(); const ref = useMergeRefs([refs.setFloating, propRef]); - if (!floatingContext.open) { + if (!open) { return null; } - const menuOptions = createMenuOptions(props, props.onItemClick); + const menuOptions = createMenuOptions(props, props.onItemClick, props.listRef, props.activeIndex, getItemProps); return (
    @@ -42,29 +55,50 @@ 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, + getItemProps?: GetItemProps ): 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 (
  • { - e.preventDefault(); - e.stopPropagation(); - handleOnClickItem(item.action); + ref={el => { + listRef.current[currentItemIndex] = el; }} + role="menuitem" + tabIndex={isActive ? 0 : -1} + className={classNames("popupmenu-basic-item", pickedStyle)} + {...getItemProps?.({ + 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 +108,36 @@ 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"} + {...getItemProps?.({ + 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..3fc78de9df 100644 --- a/packages/pluggableWidgets/popup-menu-web/src/components/PopupTrigger.tsx +++ b/packages/pluggableWidgets/popup-menu-web/src/components/PopupTrigger.tsx @@ -1,23 +1,32 @@ import { useMergeRefs } from "@floating-ui/react"; -import { forwardRef, PropsWithChildren, ReactElement, RefObject } from "react"; +import { forwardRef, PropsWithChildren, ReactElement, RefObject, useEffect, useRef } 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 ref = useMergeRefs([refs.setReference, propRef, childrenRef]); + const innerRef = useRef(null); + const wrapperRef = useMergeRefs([refs.setReference, propRef, innerRef]); + + // Apply ARIA attributes to the first interactive element inside the trigger + // Uses DOM manipulation since Mendix widgets don't forward arbitrary props + useEffect(() => { + const interactiveElement = innerRef.current?.querySelector( + "button, a, [role='button'], input" + ); + + if (interactiveElement) { + interactiveElement.setAttribute("aria-haspopup", "menu"); + interactiveElement.setAttribute("aria-expanded", String(open)); + } + }, [open]); return (
    { - e.stopPropagation(); - } - })} + {...getReferenceProps?.({ onClick: e => e.stopPropagation() })} > {children}
    diff --git a/packages/pluggableWidgets/popup-menu-web/src/hooks/usePopup.ts b/packages/pluggableWidgets/popup-menu-web/src/hooks/usePopup.ts index dfcd02f25c..37cdacaf42 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,10 +25,13 @@ interface PopupOptions { onOpenChange?: (open: boolean) => void; clippingStrategy?: ClippingStrategyEnum; trigger?: TriggerEnum; + listRef?: MutableRefObject>; + activeIndex?: number | null; + onNavigate?: (index: number | null) => void; } type FloatingReturn = Pick; -type InteractionReturn = Pick; +type InteractionReturn = Pick; export type UsePopupReturn = FloatingReturn & InteractionReturn & { @@ -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,17 +62,30 @@ 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, getItemProps } = useInteractions([ + dismiss, + role, + click, + hover, + listNav + ]); return { context, floatingStyles, getFloatingProps, getReferenceProps, + getItemProps, modal, open, refs