Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions packages/pluggableWidgets/popup-menu-web/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>("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
5 changes: 5 additions & 0 deletions packages/pluggableWidgets/popup-menu-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions packages/pluggableWidgets/popup-menu-web/e2e/PopupMenu.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ describe("Menu", () => {
],
customItems: [customItemProps],
onItemClick: jest.fn(),
clippingStrategy: "absolute"
clippingStrategy: "absolute",
listRef: { current: [] },
activeIndex: 0
};

it("renders menu", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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(<Menu {...testProps} />);

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(<Menu {...testProps} />);

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(<Menu {...testProps} />);

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(<Menu {...testProps} />);

const menuItems = container.querySelectorAll(".popupmenu-custom-item");

menuItems.forEach(item => {
expect(item).toHaveAttribute("role", "menuitem");
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,25 @@ exports[`Menu renders menu 1`] = `
class="widget-popupmenu-root"
>
<ul
aria-labelledby=":r1:"
aria-orientation="vertical"
class="popupmenu-menu"
data-floating-ui-focusable=""
id=":r0:"
role="dialog"
role="menu"
style="position: absolute; left: 0px; top: 0px; transform: translate(0px, 0px);"
tabindex="0"
tabindex="-1"
>
<li
class="popupmenu-basic-item"
role="menuitem"
tabindex="0"
>
Caption
</li>
<li
class="popupmenu-basic-divider"
role="separator"
/>
</ul>
</div>
Expand Down
Loading
Loading