Skip to content

Commit

Permalink
feat(comps): add re-usable buttons
Browse files Browse the repository at this point in the history
  • Loading branch information
trevor-anderson committed May 7, 2023
1 parent 6c88887 commit 25b4250
Show file tree
Hide file tree
Showing 5 changed files with 228 additions and 4 deletions.
118 changes: 118 additions & 0 deletions src/components/Buttons/ActionsButtonGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { useState, useRef } from "react";
import Button from "@mui/material/Button";
import ButtonGroup, { type ButtonGroupProps } from "@mui/material/ButtonGroup";
import ClickAwayListener from "@mui/material/ClickAwayListener";
import Grow from "@mui/material/Grow";
import MenuItem from "@mui/material/MenuItem";
import MenuList from "@mui/material/MenuList";
import Paper from "@mui/material/Paper";
import Popper from "@mui/material/Popper";
import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";

/**
* A Mui ButtonGroup which will render a drop-down menu when more than one
* option is provided to the `options` prop.
*
* - The "primary" option is rendered as a regular button next to the drop-down
* icon button.
* - Set any option as the "primary" option via the `isPrimary` key option key.
* - If no option has `isPrimary: true`, the 0-index option is chosen as the
* primary option.
* - Use the `updatePrimaryOptionOnClick` prop to have a dynamic "primary"
* option which updates upon menu-item selection.
*/
export const ActionsButtonGroup = ({
options,
updatePrimaryOnClick = true,
variant = "contained",
...buttonGroupProps
}: ActionsButtonGroupProps) => {
const anchorRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState<boolean>(false);
const [primaryOptionIndex, setPrimaryOptionIndex] = useState<number>(() =>
options.reduce((accum, { isPrimary = false }, idx) => (isPrimary ? idx : accum), 0)
);

const handleMenuItemClick = (
event: React.MouseEvent<HTMLLIElement, MouseEvent>,
index: number
) => {
if (updatePrimaryOnClick) setPrimaryOptionIndex(index);
options[index].handleClick(event);
setIsOpen(false);
};

const handleClose = (event: Event) => {
if (!anchorRef.current || !anchorRef.current.contains(event.target as HTMLElement)) {
setIsOpen(false);
}
};

return (
<>
<ButtonGroup ref={anchorRef} variant={variant} {...buttonGroupProps}>
<Button onClick={options[primaryOptionIndex].handleClick}>
{options[primaryOptionIndex].label}
</Button>
<Button
onClick={() => setIsOpen((prevOpen) => !prevOpen)}
size="small"
aria-haspopup="menu"
{...(isOpen && {
"aria-controls": ariaElementIDs.menu,
"aria-expanded": "true",
})}
>
<ArrowDropDownIcon />
</Button>
</ButtonGroup>
<Popper
open={isOpen}
anchorEl={anchorRef.current}
role={undefined}
transition
disablePortal
placement="bottom-end"
style={{ zIndex: 1 }}
>
{({ TransitionProps }) => (
<Grow {...TransitionProps}>
<Paper>
<ClickAwayListener onClickAway={handleClose}>
<MenuList id={ariaElementIDs.menu} autoFocusItem disablePadding>
{options.map(({ label, isDisabled = false }, index) => (
<MenuItem
key={label.replace(/\s/g, "-")}
onClick={(event) => handleMenuItemClick(event, index)}
selected={index === primaryOptionIndex}
disabled={isDisabled}
>
{label}
</MenuItem>
))}
</MenuList>
</ClickAwayListener>
</Paper>
</Grow>
)}
</Popper>
</>
);
};

const ariaElementIDs = {
menu: "split-actions-button-menu",
};

export type ActionsButtonGroupProps = {
options: ActionsButtonGroupOptions;
updatePrimaryOnClick?: boolean;
} & ButtonGroupProps;

export type ActionsButtonGroupOptions = Array<ActionsButtonGroupOption>;
export interface ActionsButtonGroupOption {
label: string;
handleClick: React.MouseEventHandler<HTMLButtonElement | HTMLLIElement>;
isPrimary?: boolean;
isDisabled?: boolean;
}
43 changes: 43 additions & 0 deletions src/components/Buttons/CreateItemButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useNavigate } from "react-router-dom";
import Button from "@mui/material/Button";
import AddCircleIcon from "@mui/icons-material/AddCircle";
import { usePageLayoutContext } from "@app/PageLayoutContext/usePageLayoutContext";
import { MobileCreateItemButton } from "./MobileCreateItemButton";

/**
* Layout-dependant CreateItemButton.
*/
export const CreateItemButton = ({ createItemFormPath, buttonText }: CreateItemButtonProps) => {
const nav = useNavigate();
const { isMobilePageLayout } = usePageLayoutContext();

const handleClickCreateItem: React.MouseEventHandler<HTMLButtonElement> = () =>
nav(createItemFormPath);

return (
<>
{isMobilePageLayout ? (
<MobileCreateItemButton onClick={handleClickCreateItem} />
) : (
<Button
onClick={handleClickCreateItem}
startIcon={<AddCircleIcon style={{ marginBottom: "0.12rem" }} />}
style={{
height: "2rem",
width: "14rem",
paddingTop: "0.26rem",
paddingBottom: "0.1rem",
borderRadius: "1.5rem",
}}
>
{buttonText}
</Button>
)}
</>
);
};

export type CreateItemButtonProps = {
createItemFormPath: string;
buttonText: string;
};
50 changes: 50 additions & 0 deletions src/components/Buttons/FlashyIconButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { styled } from "@mui/material/styles";
import IconButton from "@mui/material/IconButton";

/**
* An IconButton with a subtle yet distinguishable flash/shine animation created by a
* gradient-background rotating around the border of a Mui IconButton. The animation
* uses a custom cubic-bezier curve which gives it the appearance of inertial motion.
*
* - For circular icons with a "filled" background, pass `shouldInvertColors=true`
* for a more preferable appearance.
*/
export const FlashyIconButton = styled(IconButton, {
shouldForwardProp: (propName: string) => propName !== "shouldInvertColors",
})<{ shouldInvertColors?: boolean }>(({ theme: { palette }, shouldInvertColors = false }) => ({
height: "2rem",
width: "2rem",
color: shouldInvertColors ? palette.primary.main : palette.background.default,
position: "relative",
zIndex: 2,

"&::before": {
content: '""',
position: "absolute",
height: "2.25rem",
width: "2.25rem",
background: `conic-gradient(${palette.primary.dark} 75%, ${palette.primary.main}, ${palette.primary.dark})`,
borderRadius: "50%",
zIndex: -1,
animation: "rotate 1.5s cubic-bezier(.14,.36,.94,.71) infinite",

"@keyframes rotate": {
from: { transform: "rotate(0deg)" },
to: { transform: "rotate(360deg)" },
},
},

"& svg": {
fontSize: "2rem",
borderRadius: "50%",
...(shouldInvertColors
? {
backgroundColor: palette.background.default,
border: "none",
}
: {
backgroundColor: palette.primary.main,
border: `3px solid ${palette.background.default}`,
}),
},
}));
13 changes: 13 additions & 0 deletions src/components/Buttons/MobileCreateItemButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import AddCircleIcon from "@mui/icons-material/AddCircle";
import { FlashyIconButton } from "./FlashyIconButton";

export const MobileCreateItemButton = (props: MobileCreateItemButtonProps) => (
<FlashyIconButton shouldInvertColors {...props}>
<AddCircleIcon />
</FlashyIconButton>
);

export type MobileCreateItemButtonProps = Omit<
React.ComponentProps<typeof FlashyIconButton>,
"shouldInvertColors"
>;
8 changes: 4 additions & 4 deletions src/components/Buttons/ToggleButtonWithTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@ import { forwardRef, type ForwardedRef } from "react";
import ToggleButton, { type ToggleButtonProps } from "@mui/material/ToggleButton";
import Tooltip, { type TooltipProps } from "@mui/material/Tooltip";

export type ToggleButtonWithTooltipProps = {
TooltipProps: Omit<TooltipProps, "children">;
} & ToggleButtonProps;

/**
* Mui ToggleButton forwardRef-wrapped by Mui Tooltip.
*/
Expand All @@ -21,3 +17,7 @@ export const ToggleButtonWithTooltip = forwardRef(
);
}
);

export type ToggleButtonWithTooltipProps = {
TooltipProps: Omit<TooltipProps, "children">;
} & ToggleButtonProps;

0 comments on commit 25b4250

Please sign in to comment.