Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
import { logger } from '@php-wasm/logger';
import {
Button,
DropdownMenu,
Icon,
MenuGroup,
MenuItem,
Expand Down Expand Up @@ -75,6 +74,7 @@ import {
setDockOperationNotice,
setDockPaneOpen,
} from '../../lib/state/redux/slice-ui';
import { DropdownMenu } from '../dropdown-menu';
import { MenuItemWithDescription } from '../menu-item-with-description';
import styles from './blueprint-bundle-editor.module.css';
import hideRootStyles from './hide-root.module.css';
Expand Down Expand Up @@ -1094,8 +1094,8 @@ export const BlueprintBundleEditor = forwardRef<
'Run will wait for this Playground to finish saving.'
) : (
<>
Running this Blueprint creates a fresh
autosaved Playground. “
Running this Blueprint creates a
fresh autosaved Playground. “
{site?.metadata.name}” stays in{' '}
{isAutosaved
? 'Recent autosaves'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.menu :global(.components-menu-item__button:focus:not(:disabled)) {
box-shadow: inset 0 0 0 2px
var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9));
}
118 changes: 118 additions & 0 deletions packages/playground/website/src/components/dropdown-menu.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// @vitest-environment jsdom

import { MenuGroup } from '@wordpress/components';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { DropdownMenu } from './dropdown-menu';

describe('DropdownMenu', () => {
let container: HTMLDivElement;
let root: Root;

beforeAll(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.stubGlobal(
'matchMedia',
vi.fn((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}))
);
});

afterAll(() => {
vi.unstubAllGlobals();
});

beforeEach(() => {
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
});

afterEach(() => {
act(() => root.unmount());
container.remove();
});

it('adds Home and End navigation without replacing the menu key handler', async () => {
const onKeyDown = vi.fn();
await act(async () => {
root.render(
<DropdownMenu
icon={null}
label="Actions"
menuProps={{ onKeyDown }}
popoverProps={{ focusOnMount: false }}
>
{() => (
<MenuGroup>
<button type="button" role="menuitem">
First
</button>
<button type="button" role="menuitem">
Middle
</button>
<button type="button" role="menuitem">
Last
</button>
</MenuGroup>
)}
</DropdownMenu>
);
});

await act(async () => getButton('Actions').click());

const menu = document.querySelector<HTMLElement>(
'[role="menu"][aria-label="Actions"]'
);
const menuItems =
menu?.querySelectorAll<HTMLElement>('[role="menuitem"]');
if (!menuItems || menuItems.length !== 3) {
throw new Error(
'Expected the Actions menu to contain three items.'
);
}

act(() => {
menuItems[1].focus();
menuItems[1].dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Home',
code: 'Home',
bubbles: true,
})
);
});
expect(document.activeElement).toBe(menuItems[0]);

act(() => {
menuItems[0].dispatchEvent(
new KeyboardEvent('keydown', {
key: 'End',
code: 'End',
bubbles: true,
})
);
});
expect(document.activeElement).toBe(menuItems[2]);
expect(onKeyDown).toHaveBeenCalledTimes(2);
});

function getButton(name: string): HTMLButtonElement {
const button = container.querySelector<HTMLButtonElement>(
`button[aria-label="${name}"]`
);
if (!button) {
throw new Error(`Button not found: ${name}`);
}
return button;
}
});
55 changes: 55 additions & 0 deletions packages/playground/website/src/components/dropdown-menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { DropdownMenu as WordPressDropdownMenu } from '@wordpress/components';
import classNames from 'classnames';
import type { ComponentProps } from 'react';
import css from './dropdown-menu.module.css';

type DropdownMenuProps = ComponentProps<typeof WordPressDropdownMenu>;

export function DropdownMenu({
menuProps,
...dropdownMenuProps
}: DropdownMenuProps) {
const onKeyDown = menuProps?.onKeyDown;

return (
<WordPressDropdownMenu
{...dropdownMenuProps}
menuProps={{
...menuProps,
className: classNames(css.menu, menuProps?.className),
onKeyDown: (event) => {
onKeyDown?.(event);
if (!event.defaultPrevented) {
focusMenuBoundary(event);
}
},
}}
/>
);
}

/**
* Handles Home and End for the current menu without moving focus into a
* nested menu.
*/
function focusMenuBoundary(event: KeyboardEvent) {
if (event.key !== 'Home' && event.key !== 'End') {
return;
}

// WordPress components wrap arrow navigation, but do not handle these
// boundary keys. Supply them once for every Playground dropdown menu.
const menu = event.currentTarget as HTMLElement;
const menuItems = Array.from(
menu.querySelectorAll<HTMLElement>(
'[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
)
).filter((item) => item.closest('[role="menu"]') === menu);
const target =
event.key === 'Home' ? menuItems[0] : menuItems[menuItems.length - 1];

if (target) {
event.preventDefault();
target.focus();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import classNames from 'classnames';
import { createPortal } from 'react-dom';
import {
Spinner,
DropdownMenu,
MenuGroup,
MenuItem,
TextControl,
Expand Down Expand Up @@ -69,6 +68,7 @@ import useFetch from '../../lib/hooks/use-fetch';
import { PlaygroundRoute, redirectTo } from '../../lib/state/url/router';
import { OverlaySection } from '../overlay';
import { TruncatedText } from '../truncated-text';
import { DropdownMenu } from '../dropdown-menu';
import { MenuItemWithDescription } from '../menu-item-with-description';
import { isOpfsAvailable } from '../../lib/state/opfs/opfs-site-storage';
import type { DockPaneHeaderOverride } from '../dock/dock-pane';
Expand Down
Loading
Loading