mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-05-08 08:58:38 +01:00
Migrate react-contextmenu menus to axo menus
Co-authored-by: Fedor Indutny <indutny@signal.org>
This commit is contained in:
@@ -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>
|
||||
* -----------------------------------
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user