Skip to content

Commit

Permalink
feat: add context menu dropdown (#317)
Browse files Browse the repository at this point in the history
  • Loading branch information
Sebastien-Ahkrin authored Nov 8, 2022
1 parent 3d3e9e6 commit f71c83c
Show file tree
Hide file tree
Showing 3 changed files with 166 additions and 5 deletions.
64 changes: 60 additions & 4 deletions src/components/menu/DropdownMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import styled from '@emotion/styled';
import { Menu } from '@headlessui/react';
import type { Placement } from '@popperjs/core';
import { ReactNode, useRef, useState } from 'react';
Expand All @@ -8,6 +9,7 @@ import { useOnClickOutside } from '../hooks/useOnClickOutside';
import { useOnOff } from '../hooks/useOnOff';

import { MenuItems, MenuOption, MenuOptions } from './MenuItems';
import { useContextMenuPlacement } from './useContextMenuPlacement';

interface DropdownMenuBaseProps<T> {
/**
Expand All @@ -24,18 +26,72 @@ interface DropdownMenuClickProps<T> extends DropdownMenuBaseProps<T> {
* Node to be inside the Button
*/
children: ReactNode;
trigger: 'click';
}

interface DropdownMenuContextProps<T> extends DropdownMenuBaseProps<T> {
trigger: 'contextMenu';
children: ReactNode;
}

export type DropdownMenuProps<T> =
// | DropdownMenuContextProps<T>
DropdownMenuClickProps<T>;
| DropdownMenuContextProps<T>
| DropdownMenuClickProps<T>;

export default function DropdownMenu<T>(props: DropdownMenuProps<T>) {
return <DropdownClickMenu {...props}>{props.children}</DropdownClickMenu>;
const { trigger, ...otherProps } = props;

if (trigger === 'contextMenu') {
return <DropdownContextMenu {...otherProps} />;
}

return (
<DropdownClickMenu {...otherProps}>{props.children}</DropdownClickMenu>
);
}

const HandleMenuContextDiv = styled.div`
display: contents;
`;

function DropdownContextMenu<T>(props: Omit<DropdownMenuProps<T>, 'trigger'>) {
const { children, ...otherProps } = props;

const {
isPopperElementOpen,
closePopperElement,
handleContextMenu,
setPopperElement,
styles,
attributes,
} = useContextMenuPlacement(otherProps.placement || 'right-start');

const ref = useRef<HTMLDivElement>(null);
useOnClickOutside(ref, closePopperElement);

return (
<HandleMenuContextDiv onContextMenu={handleContextMenu}>
{isPopperElementOpen && (
<div ref={ref}>
<div
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<Menu>
<MenuItems itemsStatic {...otherProps} />
</Menu>
</div>
</div>
)}

{children}
</HandleMenuContextDiv>
);
}

function DropdownClickMenu<T>(
props: DropdownMenuProps<T> & { children: ReactNode },
props: Omit<DropdownMenuProps<T>, 'trigger'> & { children: ReactNode },
) {
const { placement = 'bottom-start', ...otherProps } = props;

Expand Down
76 changes: 76 additions & 0 deletions src/components/menu/useContextMenuPlacement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { Placement } from '@popperjs/core';
import React, { useCallback, useMemo, useState } from 'react';
import { usePopper } from 'react-popper';

interface PositionState {
clientX: number;
clientY: number;
pageX: number;
pageY: number;
}

export function useContextMenuPlacement(placement: Placement) {
const [positionState, setPositionState] = useState<PositionState | null>(
null,
);

const handleContextMenu = useCallback(
(event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
const { clientX, clientY, pageX, pageY } = event;
event.preventDefault();

setPositionState({
clientX,
clientY,
pageX,
pageY,
});
},
[],
);

const boudingClientRect = useMemo(() => {
return {
top: positionState?.clientY || 0,
left: positionState?.clientX || 0,
x: positionState?.pageX || 0,
y: positionState?.pageY || 0,

bottom: 0,
right: 0,
height: 0,
width: 0,
toJSON: () => '',
};
}, [positionState]);

const virtualElement = useMemo(() => {
return {
getBoundingClientRect: () => boudingClientRect,
};
}, [boudingClientRect]);

const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
null,
);

const { styles, attributes, state } = usePopper(
virtualElement,
popperElement,
{
placement,
},
);

return {
setPopperElement,
styles,
attributes,
popperState: state,
isPopperElementOpen: positionState !== null,
handleContextMenu,
closePopperElement: () => {
setPositionState(null);
},
};
}
31 changes: 30 additions & 1 deletion stories/components/dropdown.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,41 @@ export function Dropdown() {
}, []);

return (
<DropdownMenu onSelect={() => {}} options={options}>
<DropdownMenu trigger="click" onSelect={() => {}} options={options}>
<ButtonStyled>Default workspace</ButtonStyled>
</DropdownMenu>
);
}

const DivContextDropdown = styled.div`
height: 500px;
width: 500px;
border: 1px solid black;
display: flex;
justify-content: center;
align-items: center;
`;

export function ContextDropdown() {
const options = useMemo<MenuOptions<string>>(() => {
return [
{ label: 'Default workspace', type: 'option' },
{ label: 'Exercise', type: 'option', icon: <FaMeteor /> },
{ type: 'divider' },
{ label: 'Test', type: 'option', disabled: true },
{ label: 'Test 2', type: 'option' },
];
}, []);

return (
<DropdownMenu trigger="contextMenu" onSelect={() => {}} options={options}>
<DivContextDropdown tabIndex={1}>
<p>Hello, World!</p>
</DivContextDropdown>
</DropdownMenu>
);
}

export function WithIcon() {
const options = useMemo<MenuOptions<string>>(() => {
return [
Expand Down

0 comments on commit f71c83c

Please sign in to comment.