Skip to content

Commit

Permalink
Merge pull request #46 from balena-io-modules/refactor-dropdown-button
Browse files Browse the repository at this point in the history
Refactor Dropdown component to add menu support
  • Loading branch information
flowzone-app[bot] authored Feb 8, 2024
2 parents bd679f0 + 2460d63 commit 205a61c
Show file tree
Hide file tree
Showing 3 changed files with 155 additions and 104 deletions.
3 changes: 2 additions & 1 deletion src/components/ButtonWithTracking/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import { Button, ButtonProps, Tooltip } from '@mui/material';
import { Button, ButtonProps } from '@mui/material';
import { useAnalyticsContext } from '../../contexts/AnalyticsContext';
import { Tooltip } from '../Tooltip';

export interface ButtonWithTrackingProps extends ButtonProps {
eventName: string;
Expand Down
254 changes: 152 additions & 102 deletions src/components/DropDownButton/index.tsx
Original file line number Diff line number Diff line change
@@ -1,140 +1,190 @@
import * as React from 'react';
import { useMemo, useState } from 'react';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import ClickAwayListener from '@mui/material/ClickAwayListener';
import {
Button,
ButtonGroup,
ButtonGroupProps,
Tooltip,
Grow,
Paper,
Popper,
MenuItem,
MenuList,
MenuItemProps,
Menu,
ButtonProps,
} from '@mui/material';
import {
ButtonWithTracking,
ButtonWithTrackingProps,
} from '../ButtonWithTracking';
import { ButtonWithTracking } from '../ButtonWithTracking';
import { useAnalyticsContext } from '../../contexts/AnalyticsContext';
import groupBy from 'lodash/groupBy';
import flatMap from 'lodash/flatMap';
import { KeyboardArrowDown } from '@mui/icons-material';
import { Tooltip } from '../Tooltip';

type MenuItemType<T> = MenuItemWithTrackingProps &
T & {
tooltip?: string | undefined;
};

export interface DropDownButtonProps extends Omit<ButtonGroupProps, 'onClick'> {
items: Array<ButtonWithTrackingProps & { tooltip?: string | undefined }>;
export interface DropDownButtonProps<T = unknown>
extends Omit<ButtonGroupProps & ButtonProps, 'onClick'> {
items: Array<MenuItemType<T>>;
selectedItemIndex?: number;
groupByProp?: keyof T;
onClick?: (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>,
button: ButtonWithTrackingProps,
event: React.MouseEvent<HTMLButtonElement | HTMLLIElement, MouseEvent>,
item: MenuItemWithTrackingProps,
) => void;
}

/**
* This component implements a Dropdown button using MUI (This can be removed as soon as MUI implements it. Check
* progress: https://mui.com/material-ui/discover-more/roadmap/#new-components)
*/
export const DropDownButton: React.FC<DropDownButtonProps> = ({
export const DropDownButton = <T extends unknown>({
items,
selectedItemIndex = 0,
groupByProp,
onClick,
...buttonGroupProps
}) => {
const [open, setOpen] = React.useState(false);
const anchorRef = React.useRef<HTMLDivElement>(null);
const [selectedIndex, setSelectedIndex] = React.useState(selectedItemIndex);
children,
...buttonProps
}: DropDownButtonProps<T>) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [selectedIndex, setSelectedIndex] = useState(selectedItemIndex);

const handleClick: React.MouseEventHandler<HTMLButtonElement> = (event) =>
items[selectedIndex].onClick?.(event) ||
onClick?.(event, items[selectedIndex]);
// To use the groupBy pass another property on each item and define that property on groupByProp.
// const items = [{...menuItem, section: 'test1'}, {...menuItem, section: 'test2'}];
// <Dropdown groupByProp='section' .../>
const memoizedItems = useMemo(() => {
if (!groupByProp) {
return items;
}
const grouped = groupBy(items, (item) => item[groupByProp]);
const keys = Object.keys(grouped);
const lastKey = keys[keys.length - 1];

const handleMenuItemClick = (index: number) => {
setSelectedIndex(index);
setOpen(false);
};
return flatMap(grouped, (value, key) => [
...value.map((v, index) =>
key !== lastKey && index === value.length - 1
? { ...v, divider: true }
: v,
),
]).filter((item) => item);
}, [items, groupByProp]);

const handleToggle = () => {
setOpen((prevOpen) => !prevOpen);
const handleClick = (
event: React.MouseEvent<HTMLLIElement | HTMLButtonElement>,
) => {
setAnchorEl(event.currentTarget);
return (
items?.[selectedIndex]?.onClick?.(event) ??
onClick?.(event, items[selectedIndex])
);
};

const handleClose = (event: Event) => {
if (
anchorRef.current &&
anchorRef.current.contains(event.target as HTMLElement)
) {
return;
const handleMenuItemClick = (
event: React.MouseEvent<HTMLLIElement | HTMLButtonElement>,
index: number,
) => {
setSelectedIndex(index);
setAnchorEl(null);
if (children) {
return (
items?.[index]?.onClick?.(event) ??
onClick?.(event, items[selectedIndex])
);
}
};

setOpen(false);
const handleToggle = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};

return (
<React.Fragment>
<ButtonGroup
variant="contained"
ref={anchorRef}
aria-label="split button"
disableElevation
{...buttonGroupProps}
>
<ButtonWithTracking
onClick={handleClick}
eventName={items[selectedIndex].eventName}
eventProperties={items[selectedIndex].eventProperties}
tooltip={items[selectedIndex].tooltip}
>
{items[selectedIndex].children}
</ButtonWithTracking>
<>
{children ? (
<Button
size="small"
aria-controls={open ? 'split-button-menu' : undefined}
aria-expanded={open ? 'true' : undefined}
aria-label="actions"
aria-haspopup="menu"
onClick={handleToggle}
// It doesn't look good without it, hence the addition.
sx={(theme) => ({ pl: 2, pr: `calc(${theme.spacing(2)} + 2px)` })}
aria-controls={!!anchorEl ? 'dropdown' : undefined}
aria-expanded={!!anchorEl ? 'true' : undefined}
onClick={(event) => {
setAnchorEl(event.currentTarget);
}}
endIcon={<KeyboardArrowDown />}
{...(buttonProps as ButtonProps)}
>
<ArrowDropDownIcon />
{children}
</Button>
</ButtonGroup>
<Popper
sx={{
zIndex: 1,
) : (
<ButtonGroup
variant="contained"
disableElevation
{...(buttonProps as ButtonGroupProps)}
>
<ButtonWithTracking
onClick={handleClick}
eventName={items[selectedIndex].eventName}
eventProperties={items[selectedIndex].eventProperties}
tooltip={items[selectedIndex].tooltip}
>
{items[selectedIndex].children}
</ButtonWithTracking>
<Button
onClick={handleToggle}
// It doesn't look good without it, hence the addition.
sx={(theme) => ({ pl: 2, pr: `calc(${theme.spacing(2)} + 2px)` })}
>
<ArrowDropDownIcon />
</Button>
</ButtonGroup>
)}
<Menu
anchorEl={anchorEl}
open={!!anchorEl}
onClose={() => {
setAnchorEl(null);
}}
open={open}
anchorEl={anchorRef.current}
role={undefined}
transition
disablePortal
>
{({ TransitionProps, placement }) => (
<Grow
{...TransitionProps}
style={{
transformOrigin:
placement === 'bottom' ? 'center top' : 'center bottom',
}}
{memoizedItems.map((item, index) => (
<MenuItemWithTracking
{...item}
onClick={(event) => handleMenuItemClick(event, index)}
>
<Paper>
<ClickAwayListener onClickAway={handleClose}>
<MenuList id="split-button-menu" autoFocusItem>
{items.map((option, index) => (
<Tooltip
title={option.tooltip}
key={option.id ?? option.key ?? index}
>
<MenuItem
disabled={option.disabled}
selected={index === selectedIndex}
onClick={() => handleMenuItemClick(index)}
>
{option.children}
</MenuItem>
</Tooltip>
))}
</MenuList>
</ClickAwayListener>
</Paper>
</Grow>
)}
</Popper>
</React.Fragment>
{item.children}
</MenuItemWithTracking>
))}
</Menu>
</>
);
};

export interface MenuItemWithTrackingProps
extends Omit<MenuItemProps, 'onClick'> {
eventName: string;
eventProperties?: { [key: string]: any };
tooltip?: string;
onClick?: React.MouseEventHandler<HTMLLIElement | HTMLButtonElement>;
}

/**
* This MenuItem will send analytics in case the analytics context is passed through the provider (AnalyticsProvider).
*/
export const MenuItemWithTracking: React.FC<MenuItemWithTrackingProps> = ({
eventName,
eventProperties,
children,
tooltip,
onClick,
...menuItem
}) => {
const { state } = useAnalyticsContext();

const handleClick = (event: React.MouseEvent<HTMLLIElement, MouseEvent>) => {
if (state.webTracker) {
state.webTracker.track(eventName, eventProperties);
}
onClick?.(event);
};

return (
<Tooltip title={tooltip}>
<MenuItem {...menuItem} onClick={handleClick}>
{children}
</MenuItem>
</Tooltip>
);
};
2 changes: 1 addition & 1 deletion src/components/Tooltip/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { TooltipProps, Tooltip as MuiTooltip } from '@mui/material';
const Tooltip: React.FC<TooltipProps> = ({ children, ...tooltipProps }) => {
return (
<MuiTooltip {...tooltipProps}>
<span>{children}</span>
{children.props.disabled ? <span>{children}</span> : children}
</MuiTooltip>
);
};
Expand Down

0 comments on commit 205a61c

Please sign in to comment.