Unverified Commit 0e4a50d2 authored by gabriellsh's avatar gabriellsh Committed by GitHub
Browse files

feat: Kebab Menu w/ popover (#211)

parent b593875f
......@@ -14,7 +14,7 @@ const top = (top) => ({ top });
const left = (left) => ({ left });
const right = (right) => ({ right });
function offset(el) {
function getOffset(el) {
return el.getBoundingClientRect();
}
......@@ -55,7 +55,7 @@ const throttle = (func, limit) => {
};
};
export const Position = ({ anchor, width = 'stretch', style, className, children, placement = 'bottom center' }) => {
export const Position = ({ anchor, width = 'stretch', style, className, children, placement = 'bottom center'/* , offset*/ }) => {
const [position, setPosition] = useState();
const ref = useRef();
......@@ -69,8 +69,8 @@ export const Position = ({ anchor, width = 'stretch', style, className, children
const [vertical, horizontal] = placement.split(' ');
const handlePosition = throttle(() => {
const anchorPosition = offset(anchor.current);
const elementPosition = offset(ref.current.parentElement);
const anchorPosition = getOffset(anchor.current);
const elementPosition = getOffset(ref.current.parentElement);
setPosition({
...width === 'stretch' && anchor.current && {
......@@ -103,7 +103,7 @@ export const Position = ({ anchor, width = 'stretch', style, className, children
window.removeEventListener('resize', handlePosition);
resizer.current && resizer.current.unobserve(current);
};
}, [anchor.current, placement, offsetWidth]);
}, [anchor, placement, offsetWidth, width]);
const portalContainer = useMemo(() => {
const element = document.createElement('div');
......@@ -111,7 +111,7 @@ export const Position = ({ anchor, width = 'stretch', style, className, children
return element;
}, []);
useEffect(() => () => document.body.removeChild(portalContainer), []);
useEffect(() => () => document.body.removeChild(portalContainer), [portalContainer]);
return ReactDOM.createPortal(
React.cloneElement(children, {
......@@ -131,6 +131,6 @@ export const Position = ({ anchor, width = 'stretch', style, className, children
);
};
export const PositionAnimated = ({ width, placement, visible, children, ...props }) => (
<AnimatedVisibility visibility={visible}><Position placement={placement} width={width} {...props}>{children}</Position></AnimatedVisibility>
export const PositionAnimated = ({ width, offset, placement, visible, children, ...props }) => (
<AnimatedVisibility visibility={visible}><Position offset={offset} placement={placement} width={width} {...props}>{children}</Position></AnimatedVisibility>
);
import React, { useRef, useCallback } from 'react';
import {
Button,
PositionAnimated,
Options,
Icon,
useCursor,
} from '..';
const menuAction = ([selected], options) => {
options[selected].action();
};
const mapOptions = (options) => Object.entries(options).map(([value, { label }]) => [value, label]);
export const Menu = ({
options,
optionWidth = '240px',
placement = 'bottom right',
...props }) => {
const mappedOptions = mapOptions(options);
const [cursor, handleKeyDown, handleKeyUp, reset, [visible, hide, show]] = useCursor(-1, mappedOptions, (args, [, hide]) => {
menuAction(args, options);
reset();
hide();
});
const ref = useRef();
const onClick = useCallback(() => ref.current.focus() & show(), [show]);
const handleSelection = useCallback((args) => {
menuAction(args, options);
reset();
hide();
}, [hide, reset, options]);
return (
<>
<Button
ref={ref}
small
ghost
onClick={onClick}
onBlur={hide}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
{...props}
>
<Icon name='menu' size={20} />
</Button>
<PositionAnimated
width='auto'
visible={visible}
anchor={ref}
placement={placement}
>
<Options
width={optionWidth}
onSelect={handleSelection}
options={mappedOptions}
cursor={cursor}
/>
</PositionAnimated>
</>
);
};
import { action } from '@storybook/addon-actions';
import { Meta, Preview, Props, Story } from '@storybook/addon-docs/blocks';
// import LinkTo from '@storybook/addon-links/react';
// import { PropsVariationSection } from '../../../.storybook/helpers';
import { Box, Icon, Menu } from '../'
const options = {
'makeAdmin': {
label: <Box display='flex' alignItems='center'><Icon mie='x4' name='key' size='x16'/>Make Admin</Box>,
action: () => console.log('[...] is now admin'),
},
'delete': {
label: <Box display='flex' alignItems='center' textColor='danger'><Icon mie='x4' name='trash' size='x16'/>Delete</Box>,
action: () => console.log('[...] no longer exists'),
}
}
<Meta title='Misc/Menu' parameters={{ jest: ['Menu/spec'] }} />
# Menu
Kebab Menu
<Preview>
<Story name='Default'>
<Box style={{ position: 'relative', maxWidth: 250 }} >
<Menu options={options} />
</Box>
</Story>
</Preview>
<Props of={Menu} />
import React, { useCallback, useLayoutEffect, useState, forwardRef } from 'react';
import React, { useCallback, useLayoutEffect, useState, forwardRef, useMemo } from 'react';
import { AnimatedVisibility, Box, Flex, Margins, Scrollable } from '../Box';
......@@ -42,6 +42,7 @@ export const OptionAvatar = React.memo(({ id, value, children: label, focus, sel
export const Options = React.forwardRef(({
maxHeight = '144px',
width = '240px',
multiple,
renderEmpty: EmptyComponent = Empty,
options,
......@@ -59,14 +60,16 @@ export const Options = React.forwardRef(({
if (li.offsetTop + li.clientHeight > current.scrollTop + current.clientHeight || li.offsetTop - li.clientHeight < current.scrollTop) {
current.scrollTop = li.offsetTop;
}
}, [cursor]);
}, [cursor, ref]);
const optionsMemoized = useMemo(() => options.map(([value, label, selected], i) => <OptionComponent role='option' onMouseDown={(e) => prevent(e) & onSelect([value, label]) && false} key={value} value={value} selected={selected || (multiple !== true && null)} focus={cursor === i || null}>{label}</OptionComponent>), [options, multiple, cursor, onSelect]);
return <Box rcx-options is='div' {...props}>
<Tile padding='x8' elevation='2'>
<Scrollable vertical smooth>
<Margins blockStart='x4'>
<Tile ref={ref} elevation='0' padding='none' maxHeight={maxHeight} onMouseDown={prevent} onClick={prevent} is='ol' aria-multiselectable={multiple} role='listbox' aria-multiselectable='true' aria-activedescendant={options && options[cursor] && options[cursor][0]}>
<Tile ref={ref} elevation='0' padding='none' width={width} maxHeight={maxHeight} onMouseDown={prevent} onClick={prevent} is='ol' aria-multiselectable={multiple} role='listbox' aria-multiselectable='true' aria-activedescendant={options && options[cursor] && options[cursor][0]}>
{!options.length && <EmptyComponent/>}
{options.map(([value, label, selected], i) => <OptionComponent role='option' onMouseDown={(e) => prevent(e) & onSelect([value, label]) && false} key={value} value={value} selected={selected || (multiple !== true && null)} focus={cursor === i || null}>{label}</OptionComponent>)}
{optionsMemoized}
</Tile>
</Margins>
</Scrollable>
......
......@@ -40,3 +40,4 @@ export * from './Tile';
export * from './ToggleSwitch';
export * from './Tooltip';
export * from './UrlInput';
export * from './Menu';
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment