addons/shared/ContextMenu.tsxblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import {Icon} from 'isl-components/Icon';
b69ab319import {findParentWithClassName} from 'isl-components/utils';
b69ab3110import {getZoomLevel} from 'isl-components/zoom';
b69ab3111import {atom, useAtom, useSetAtom} from 'jotai';
b69ab3112import React, {useEffect, useRef, useState} from 'react';
b69ab3113
b69ab3114import './ContextMenu.css';
b69ab3115
b69ab3116/**
b69ab3117 * Hook to create a context menu in HTML.
b69ab3118 * Pass in a function that returns the list of context menu items.
b69ab3119 * Then use the result in onContextMenu:
b69ab3120 * ```
b69ab3121 * function MyComponent() {
b69ab3122 * const menu = useContextMenu(() => [
b69ab3123 * {label: 'Choice 1', onClick: () => console.log('clicked!')}
b69ab3124 * ]);
b69ab3125 * return <div onContextMenu={menu}>...</div>
b69ab3126 * }
b69ab3127 * ```
b69ab3128 */
b69ab3129export function useContextMenu<T>(
b69ab3130 creator: () => Array<ContextMenuItem>,
b69ab3131): React.MouseEventHandler<T> {
b69ab3132 const setState = useSetAtom(contextMenuState);
b69ab3133 return e => {
b69ab3134 const zoom = getZoomLevel();
b69ab3135 const items = creator();
b69ab3136 if (items.length === 0) {
b69ab3137 return;
b69ab3138 }
b69ab3139 setState({x: e.clientX / zoom, y: e.clientY / zoom, items});
b69ab3140
b69ab3141 e.preventDefault();
b69ab3142 e.stopPropagation();
b69ab3143 };
b69ab3144}
b69ab3145
b69ab3146type ContextMenuData = {x: number; y: number; items: Array<ContextMenuItem>};
b69ab3147export type ContextMenuItem =
b69ab3148 | {type?: undefined; label: string | React.ReactNode; onClick?: () => void; tooltip?: string}
b69ab3149 | {
b69ab3150 type: 'submenu';
b69ab3151 label: string | React.ReactNode;
b69ab3152 children: Array<ContextMenuItem>;
b69ab3153 }
b69ab3154 | {type: 'divider'};
b69ab3155
b69ab3156export const contextMenuState = atom<null | ContextMenuData>(null);
b69ab3157
b69ab3158export function ContextMenus() {
b69ab3159 const [state, setState] = useAtom(contextMenuState);
b69ab3160
b69ab3161 const ref = useRef<HTMLDivElement>(null);
b69ab3162
b69ab3163 useEffect(() => {
b69ab3164 if (state != null) {
b69ab3165 const hide = (e: Event) => {
b69ab3166 if (e.type === 'keyup') {
b69ab3167 if ((e as KeyboardEvent).key === 'Escape') {
b69ab3168 setState(null);
b69ab3169 }
b69ab3170 return;
b69ab3171 } else if (e.type === 'click' || e.type === 'scroll') {
b69ab3172 // if click or scroll inside the context menu, don't dismiss
b69ab3173 if (findParentWithClassName(e.target as HTMLElement, 'context-menu-container')) {
b69ab3174 return;
b69ab3175 }
b69ab3176 }
b69ab3177 setState(null);
b69ab3178 };
b69ab3179 window.addEventListener('click', hide, true);
b69ab3180 window.addEventListener('scroll', hide, true);
b69ab3181 window.addEventListener('resize', hide, true);
b69ab3182 window.addEventListener('keyup', hide, true);
b69ab3183 return () => {
b69ab3184 window.removeEventListener('click', hide, true);
b69ab3185 window.removeEventListener('scroll', hide, true);
b69ab3186 window.removeEventListener('resize', hide, true);
b69ab3187 window.removeEventListener('keyup', hide, true);
b69ab3188 };
b69ab3189 }
b69ab3190 }, [state, setState]);
b69ab3191
b69ab3192 if (state == null) {
b69ab3193 return null;
b69ab3194 }
b69ab3195
b69ab3196 const zoom = getZoomLevel();
b69ab3197 const topOrBottom = state.y > window.innerHeight / zoom / 2 ? 'bottom' : 'top';
b69ab3198 const leftOrRight = state.x > window.innerWidth / zoom / 2 ? 'right' : 'left';
b69ab3199 const yOffset = 10;
b69ab31100 const xOffset = -10; // var(--pad)
b69ab31101 let position: React.CSSProperties;
b69ab31102 if (topOrBottom === 'top') {
b69ab31103 if (leftOrRight === 'left') {
b69ab31104 position = {top: state.y + yOffset, left: state.x + xOffset};
b69ab31105 } else {
b69ab31106 position = {top: state.y + yOffset, right: window.innerWidth / zoom - (state.x - xOffset)};
b69ab31107 }
b69ab31108 } else {
b69ab31109 if (leftOrRight === 'left') {
b69ab31110 position = {bottom: window.innerHeight / zoom - (state.y - yOffset), left: state.x + xOffset};
b69ab31111 } else {
b69ab31112 position = {
b69ab31113 bottom: window.innerHeight / zoom - (state.y - yOffset),
b69ab31114 right: window.innerWidth / zoom - (state.x - xOffset),
b69ab31115 };
b69ab31116 }
b69ab31117 }
b69ab31118 position.maxHeight =
b69ab31119 window.innerHeight / zoom -
b69ab31120 ((position.top as number | null) ?? 0) -
b69ab31121 ((position.bottom as number | null) ?? 0);
b69ab31122
b69ab31123 return (
b69ab31124 <div
b69ab31125 ref={ref}
b69ab31126 className={'context-menu-container'}
b69ab31127 data-testid="context-menu-container"
b69ab31128 style={position}>
b69ab31129 {topOrBottom === 'top' ? (
b69ab31130 <div
b69ab31131 className={`context-menu-arrow context-menu-arrow-top context-menu-arrow-${leftOrRight}`}
b69ab31132 />
b69ab31133 ) : null}
b69ab31134 <ContextMenuList
b69ab31135 items={state.items}
b69ab31136 clickItem={item => {
b69ab31137 if (item.type != null) {
b69ab31138 return;
b69ab31139 }
b69ab31140 // don't allow double-clicking to run the action twice
b69ab31141 if (state != null) {
b69ab31142 item.onClick?.();
b69ab31143 setState(null);
b69ab31144 }
b69ab31145 }}
b69ab31146 />
b69ab31147
b69ab31148 {topOrBottom === 'bottom' ? (
b69ab31149 <div
b69ab31150 className={`context-menu-arrow context-menu-arrow-bottom context-menu-arrow-${leftOrRight}`}
b69ab31151 />
b69ab31152 ) : null}
b69ab31153 </div>
b69ab31154 );
b69ab31155}
b69ab31156
b69ab31157function ContextMenuList({
b69ab31158 items,
b69ab31159 clickItem,
b69ab31160}: {
b69ab31161 items: Array<ContextMenuItem>;
b69ab31162 clickItem: (item: ContextMenuItem) => void;
b69ab31163}) {
b69ab31164 // Each ContextMenuList renders one additional layer of submenu
b69ab31165 const [submenuNavigation, setSubmenuNavigation] = useState<
b69ab31166 {x: number; y: number; children: Array<ContextMenuItem>} | undefined
b69ab31167 >(undefined);
b69ab31168 const [tooltip, setTooltip] = useState<{x: number; y: number; tooltip: string} | undefined>(
b69ab31169 undefined,
b69ab31170 );
b69ab31171 const ref = useRef<HTMLDivElement | null>(null);
b69ab31172
b69ab31173 function getCoordinatesForSubElement(e: React.PointerEvent) {
b69ab31174 const target = e.currentTarget as HTMLElement;
b69ab31175 const parent = ref.current;
b69ab31176 if (!parent) {
b69ab31177 return;
b69ab31178 }
b69ab31179 const parentRect = parent?.getBoundingClientRect();
b69ab31180 const rect = target.getBoundingClientRect();
b69ab31181 // attach to top right corner
b69ab31182 const x = -1 * parentRect.left + rect.right;
b69ab31183 const y = -1 * parentRect.top + rect.top;
b69ab31184 return {x, y};
b69ab31185 }
b69ab31186
b69ab31187 return (
b69ab31188 <>
b69ab31189 <div className="context-menu" ref={ref}>
b69ab31190 {items.map((item, i) =>
b69ab31191 item.type === 'divider' ? (
b69ab31192 <div className="context-menu-divider" key={i} />
b69ab31193 ) : item.type === 'submenu' ? (
b69ab31194 <div
b69ab31195 key={i}
b69ab31196 className={'context-menu-item context-menu-submenu'}
b69ab31197 onPointerEnter={e => {
b69ab31198 const coordinates = getCoordinatesForSubElement(e);
b69ab31199 if (!coordinates) {
b69ab31200 return;
b69ab31201 }
b69ab31202 const {x, y} = coordinates;
b69ab31203 setSubmenuNavigation({
b69ab31204 x,
b69ab31205 y,
b69ab31206 children: item.children,
b69ab31207 });
b69ab31208 setTooltip(undefined);
b69ab31209 }}>
b69ab31210 <span>{item.label}</span>
b69ab31211 <Icon icon="chevron-right" />
b69ab31212 </div>
b69ab31213 ) : (
b69ab31214 <div
b69ab31215 key={i}
b69ab31216 onPointerEnter={e => {
b69ab31217 if (item.tooltip) {
b69ab31218 const coordinates = getCoordinatesForSubElement(e);
b69ab31219 if (!coordinates) {
b69ab31220 return;
b69ab31221 }
b69ab31222 const {x, y} = coordinates;
b69ab31223 setTooltip({x, y, tooltip: item.tooltip});
b69ab31224 } else {
b69ab31225 setTooltip(undefined);
b69ab31226 }
b69ab31227 setSubmenuNavigation(undefined);
b69ab31228 }}
b69ab31229 onClick={() => {
b69ab31230 clickItem(item);
b69ab31231 }}
b69ab31232 className={'context-menu-item'}>
b69ab31233 {item.label}
b69ab31234 </div>
b69ab31235 ),
b69ab31236 )}
b69ab31237 </div>
b69ab31238 {submenuNavigation != null && (
b69ab31239 <div
b69ab31240 className="context-menu-submenu-navigation"
b69ab31241 style={{position: 'absolute', top: submenuNavigation.y, left: submenuNavigation.x}}>
b69ab31242 <ContextMenuList items={submenuNavigation.children} clickItem={clickItem} />
b69ab31243 </div>
b69ab31244 )}
b69ab31245 {tooltip != null && (
b69ab31246 <div
b69ab31247 className="context-menu-tooltip"
b69ab31248 style={{position: 'absolute', top: tooltip.y, left: tooltip.x}}>
b69ab31249 {tooltip.tooltip}
b69ab31250 </div>
b69ab31251 )}
b69ab31252 </>
b69ab31253 );
b69ab31254}