Migrate react-contextmenu menus to axo menus

Co-authored-by: Fedor Indutny <indutny@signal.org>
This commit is contained in:
Jamie
2025-11-12 09:31:52 -08:00
committed by GitHub
parent 7d52f761e3
commit 714e161671
37 changed files with 1366 additions and 1693 deletions
+111 -4
View File
@@ -1,11 +1,17 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo } from 'react';
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import { ContextMenu } from 'radix-ui';
import type { FC } from 'react';
import type {
FC,
KeyboardEvent,
KeyboardEventHandler,
MouseEvent as ReactMouseEvent,
} from 'react';
import { AxoSymbol } from './AxoSymbol.dom.js';
import { AxoBaseMenu } from './_internal/AxoBaseMenu.dom.js';
import { tw } from './tw.dom.js';
import { assert } from './_internal/assert.dom.js';
const Namespace = 'AxoContextMenu';
@@ -57,7 +63,11 @@ export namespace AxoContextMenu {
export type RootProps = AxoBaseMenu.MenuRootProps;
export const Root: FC<RootProps> = memo(props => {
return <ContextMenu.Root>{props.children}</ContextMenu.Root>;
return (
<ContextMenu.Root onOpenChange={props.onOpenChange}>
{props.children}
</ContextMenu.Root>
);
});
Root.displayName = `${Namespace}.Root`;
@@ -67,14 +77,111 @@ export namespace AxoContextMenu {
* -----------------------------------
*/
type TriggerElementGetter = (event: KeyboardEvent) => Element;
// eslint-disable-next-line no-inner-declarations
function useContextMenuTriggerKeyboardEventHandler(
getTriggerElement: TriggerElementGetter
) {
const getTriggerElementRef =
useRef<TriggerElementGetter>(getTriggerElement);
useEffect(() => {
getTriggerElementRef.current = getTriggerElement;
}, [getTriggerElement]);
return useCallback(
(event: KeyboardEvent) => {
const isMacOS = window.platform === 'darwin';
if (
(isMacOS ? event.metaKey : !event.metaKey) &&
(isMacOS ? !event.ctrlKey : event.ctrlKey) &&
(isMacOS ? !event.shiftKey : event.shiftKey) &&
!event.altKey &&
(isMacOS ? event.key === 'F12' : event.key === 'F10')
) {
event.preventDefault();
event.stopPropagation();
const trigger = getTriggerElement(event);
const clientRect = trigger.getBoundingClientRect();
trigger.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
clientX: clientRect.left,
clientY: clientRect.bottom,
})
);
}
},
[getTriggerElement]
);
}
export type TriggerProps = AxoBaseMenu.MenuTriggerProps;
export const Trigger: FC<TriggerProps> = memo(props => {
return <ContextMenu.Trigger asChild>{props.children}</ContextMenu.Trigger>;
const [disableCurrentEvent, setDisableCurrentEvent] = useState(false);
const handleContextMenuCapture = useCallback(
(event: ReactMouseEvent<HTMLElement>) => {
const { target, currentTarget } = event;
if (
target instanceof HTMLElement &&
target.closest('a[href], [role=link]') != null
) {
setDisableCurrentEvent(true);
}
const selection = window.getSelection();
if (
selection != null &&
!selection.isCollapsed &&
selection.containsNode(currentTarget, true)
) {
setDisableCurrentEvent(true);
}
},
[]
);
const handleContextMenu = useCallback(() => {
setDisableCurrentEvent(false);
}, []);
const handleKeyDown = useContextMenuTriggerKeyboardEventHandler(event => {
return event.currentTarget;
});
return (
<ContextMenu.Trigger
asChild
onContextMenuCapture={handleContextMenuCapture}
onContextMenu={handleContextMenu}
onKeyDown={handleKeyDown}
disabled={disableCurrentEvent || props.disabled}
data-axo-context-menu-trigger
>
{props.children}
</ContextMenu.Trigger>
);
});
Trigger.displayName = `${Namespace}.Trigger`;
export function useAxoContextMenuOutsideKeyboardTrigger(): KeyboardEventHandler {
return useContextMenuTriggerKeyboardEventHandler(event => {
return assert(
event.currentTarget.querySelector('[data-axo-context-menu-trigger]'),
`Couldn't find <${Namespace}.Trigger> element, did you forget to pass all html props through?`
);
});
}
/**
* Component: <AxoContextMenu.Content>
* -----------------------------------
+15 -5
View File
@@ -3,7 +3,7 @@
import React, { memo, useEffect, useId, useRef } from 'react';
import { DropdownMenu } from 'radix-ui';
import type { FC, ReactNode } from 'react';
import { getRole, computeAccessibleName } from 'dom-accessibility-api';
import { computeAccessibleName } from 'dom-accessibility-api';
import { AxoSymbol } from './AxoSymbol.dom.js';
import { AxoBaseMenu } from './_internal/AxoBaseMenu.dom.js';
import { tw } from './tw.dom.js';
@@ -13,6 +13,10 @@ import {
useCreateAriaLabellingContext,
} from './_internal/AriaLabellingContext.dom.js';
import { assert } from './_internal/assert.dom.js';
import {
getElementAriaRole,
isAriaWidgetRole,
} from './_internal/ariaRoles.dom.js';
const Namespace = 'AxoDropdownMenu';
@@ -107,8 +111,8 @@ export namespace AxoDropdownMenu {
`${triggerDisplayName} child must forward ref`
);
assert(
getRole(ref.current) === 'button',
`${triggerDisplayName} child must be a <button> or role=button`
isAriaWidgetRole(getElementAriaRole(ref.current)),
`${triggerDisplayName} child must have a widget role like 'button'`
);
assert(
computeAccessibleName(ref.current) !== '',
@@ -118,7 +122,7 @@ export namespace AxoDropdownMenu {
});
return (
<DropdownMenu.Trigger ref={ref} asChild>
<DropdownMenu.Trigger ref={ref} asChild disabled={props.disabled}>
{props.children}
</DropdownMenu.Trigger>
);
@@ -412,6 +416,8 @@ export namespace AxoDropdownMenu {
<DropdownMenu.RadioItem
value={props.value}
className={AxoBaseMenu.menuRadioItemStyles}
disabled={props.disabled}
textValue={props.textValue}
onSelect={props.onSelect}
>
<AxoBaseMenu.ItemLeadingSlot>
@@ -501,7 +507,11 @@ export namespace AxoDropdownMenu {
*/
export const SubTrigger: FC<SubTriggerProps> = memo(props => {
return (
<DropdownMenu.SubTrigger className={AxoBaseMenu.menuSubTriggerStyles}>
<DropdownMenu.SubTrigger
disabled={props.disabled}
textValue={props.textValue}
className={AxoBaseMenu.menuSubTriggerStyles}
>
{props.symbol && (
<AxoBaseMenu.ItemLeadingSlot>
<AxoBaseMenu.ItemSymbol symbol={props.symbol} />
+1 -1
View File
@@ -90,7 +90,7 @@ export namespace AxoSymbol {
}>;
const iconStyles = tw(
'inline-flex size-[1em] shrink-0 items-center justify-center align-top'
'inline-flex size-[1em] shrink-0 items-center justify-center align-middle'
);
export const Icon: FC<IconProps> = memo(props => {
+12 -2
View File
@@ -4,14 +4,21 @@ import React from 'react';
import type { ReactNode } from 'react';
import { tw } from '../tw.dom.js';
import { AxoSymbol } from '../AxoSymbol.dom.js';
import { isTestOrMockEnvironment } from '../../environment.std.js';
// Pulled from $z-index-context-menu. In the future we should be relying more
// on insert order of dialogs/popovers/menus into portals
const LEGACY_CONTEXT_MENU_Z_INDEX = tw('z-[125]');
export namespace AxoBaseMenu {
// <Content/SubContent>
const baseContentStyles = tw(
LEGACY_CONTEXT_MENU_Z_INDEX,
'max-w-[300px] min-w-[200px]',
'select-none',
'rounded-xl bg-elevated-background-tertiary shadow-elevation-3',
'animate-opacity-0 data-[state=closed]:animate-exit',
isTestOrMockEnvironment() ||
'animate-opacity-0 data-[state=closed]:animate-exit',
'forced-colors:border',
'forced-colors:bg-[Canvas]',
'forced-colors:text-[CanvasText]'
@@ -168,6 +175,9 @@ export namespace AxoBaseMenu {
*/
export type MenuRootProps = Readonly<{
// Note: Radix context menus don't have an `open` prop
// so we have to push it down to the dropdown menu props
onOpenChange?: (open: boolean) => void;
children: ReactNode;
}>;
@@ -178,7 +188,7 @@ export namespace AxoBaseMenu {
export type MenuTriggerProps = Readonly<{
/**
* When true, the context menu won't open when right-clicking.
* When true, the menu won't open when right-clicking.
* Note that this will also restore the native context menu.
*/
disabled?: boolean;
+185
View File
@@ -0,0 +1,185 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { AriaRole as ReactAriaRole } from 'react';
import { getRole } from 'dom-accessibility-api';
import { assert } from './assert.dom.js';
const AbstractRoles = {
/** Abstract Roles: https://www.w3.org/TR/wai-aria-1.2/#abstract_roles */
command: true,
composite: true,
input: true,
landmark: true,
range: true,
roletype: true,
section: true,
sectionhead: true,
select: true,
structure: true,
widget: true,
window: true,
} as const satisfies Record<string, true>;
export type AbstractAriaRole = keyof typeof AbstractRoles;
export type AriaRole =
| Exclude<ReactAriaRole, object>
// Missing from React's types
| 'blockquote'
| 'caption'
| 'code'
| 'deletion'
| 'emphasis'
| 'insertion'
| 'meter'
| 'paragraph'
| 'strong'
| 'subscript'
| 'superscript'
| 'time';
type ProhibitedAriaRole = 'generic';
type AnyAriaRole = AbstractAriaRole | AriaRole | ProhibitedAriaRole;
const ParentRoles = {
/** Abstract Roles: https://www.w3.org/TR/wai-aria-1.2/#abstract_roles */
command: ['widget'],
composite: ['widget'],
input: ['widget'],
landmark: ['section'],
range: ['structure'],
roletype: [],
section: ['structure'],
sectionhead: ['structure'],
select: ['composite', 'group'],
structure: ['roletype'],
widget: ['roletype'],
window: ['roletype'],
/** Widget Roles: https://www.w3.org/TR/wai-aria-1.2/#widget_roles */
button: ['command'],
checkbox: ['input'],
gridcell: ['cell', 'widget'],
link: ['command'],
menuitem: ['command'],
menuitemcheckbox: ['menuitem'],
menuitemradio: ['menuitem'],
option: ['input'],
progressbar: ['range', 'widget'],
radio: ['input'],
scrollbar: ['range', 'widget'],
searchbox: ['textbox'],
separator: ['structure' /* no-focus */, 'widget' /* focus */],
slider: ['input', 'range'],
spinbutton: ['composite', 'input', 'range'],
switch: ['checkbox'],
tab: ['sectionhead', 'widget'],
tabpanel: ['section'],
textbox: ['input'],
treeitem: ['listitem', 'option'],
/** Composite Widget Roles */
combobox: ['input'],
grid: ['composite', 'table'],
listbox: ['select'],
menu: ['select'],
menubar: ['menu'],
radiogroup: ['select'],
tablist: ['composite'],
tree: ['select'],
treegrid: ['grid', 'tree'],
/** Document Structure Roles: https://www.w3.org/TR/wai-aria-1.2/#document_structure_roles */
application: ['structure'],
article: ['document'],
blockquote: ['section'],
caption: ['section'],
cell: ['section'],
code: ['section'],
columnheader: ['cell', 'gridcell', 'sectionhead'],
definition: ['section'],
deletion: ['section'],
directory: ['list'],
document: ['structure'],
emphasis: ['section'],
feed: ['list'],
figure: ['section'],
generic: ['structure'],
group: ['section'],
heading: ['sectionhead'],
img: ['section'],
insertion: ['section'],
list: ['section'],
listitem: ['section'],
math: ['section'],
meter: ['range'],
none: ['structure'],
note: ['section'],
paragraph: ['section'],
presentation: ['structure'],
row: ['group', 'widget'],
rowgroup: ['structure'],
rowheader: ['cell', 'gridcell', 'sectionhead'],
// skip separator
strong: ['section'],
subscript: ['section'],
superscript: ['section'],
table: ['section'],
term: ['section'],
time: ['section'],
toolbar: ['group'],
tooltip: ['section'],
/** Landmark Roles: https://www.w3.org/TR/wai-aria-1.2/#landmark_roles */
banner: ['landmark'],
complementary: ['landmark'],
contentinfo: ['landmark'],
form: ['landmark'],
main: ['landmark'],
navigation: ['landmark'],
region: ['landmark'],
search: ['landmark'],
/** Live Region Roles: https://www.w3.org/TR/wai-aria-1.2/#live_region_roles */
alert: ['section'],
log: ['section'],
marquee: ['section'],
status: ['section'],
timer: ['status'],
/** Window Roles: https://www.w3.org/TR/wai-aria-1.2/#window_roles */
alertdialog: ['alert', 'dialog'],
dialog: ['window'],
} as const satisfies Record<AnyAriaRole, ReadonlyArray<AnyAriaRole>>;
function inherits(role: AnyAriaRole, superRole: AnyAriaRole): boolean {
if (role === superRole) {
return true;
}
const parentRoles = assert(ParentRoles[role], `Unknown Aria role: ${role}`);
for (const parentRole of parentRoles) {
if (inherits(parentRole, superRole)) {
return true;
}
}
return false;
}
export function isValidAriaRole(role: string | null): role is AriaRole {
return role != null && Object.hasOwn(ParentRoles, role);
}
export function getElementAriaRole(element: Element): AriaRole | null {
const role = getRole(element);
if (isValidAriaRole(role)) {
return role;
}
return null;
}
export function isAriaWidgetRole(role: AriaRole | null): boolean {
return role != null && inherits(role, 'widget');
}