Skip to content

Commit

Permalink
Added Menu component with simple stories view
Browse files Browse the repository at this point in the history
  • Loading branch information
belousovjr committed Nov 29, 2023
1 parent cb17e55 commit eb52aa6
Show file tree
Hide file tree
Showing 6 changed files with 251 additions and 0 deletions.
103 changes: 103 additions & 0 deletions src/components/Menu/Menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { MenuBtnWrap, MenuList, MenuWrap } from "./styles";
import { IMenuProps } from "./types";
import { Text } from "../Text";
import { useCallback, useMemo, useRef, useState } from "react";
import useOnClickOutside from "../../hooks/useOnClickOutside";
import { CheckIcon } from "../Svg";
import { Flex } from "../Box";

export default function Menu<T = any>({
activator,
listWidth,
align = "center",
offsetX = 0,
offsetY = 0,
opened,
openedChange = (v: boolean) => {},
value,
onChange,
renderItem,
items = [],
valueKey,
multiple,
canByEmpty,
}: IMenuProps<T>) {
const [locOpened, setLocOpened] = useState(opened);

const menuRef = useRef<HTMLDivElement>(null);

const values = useMemo(() => (Array.isArray(value) ? value : value ? [value] : []), [value]);

const openedChangeHandler = (opened: boolean) => {
openedChange(opened);
setLocOpened(opened);
};

const isOpened = useMemo(() => (typeof opened === "boolean" ? opened : locOpened), [opened, locOpened]);

useOnClickOutside(menuRef, () => {
openedChangeHandler(false);
});

const getWithValueKey = useCallback(
(item: T) => {
return typeof valueKey === "undefined" ? item : item[valueKey];
},
[valueKey]
);

return (
<MenuWrap align={align} ref={menuRef}>
<MenuBtnWrap
onClick={() => {
openedChangeHandler(!isOpened);
}}
>
{activator || (
<Text bg={"black"} color={"textGray"} p={"4px"}>
Activator
</Text>
)}
</MenuBtnWrap>
{isOpened && (
<MenuList width={listWidth} offsetX={offsetX} offsetY={offsetY}>
{items.map((item, i) => {
const isActive = values.includes(getWithValueKey(item));
return (
<MenuBtnWrap
key={i}
onClick={() => {
if (onChange) {
const itemValue = getWithValueKey(item);
if (!multiple) {
const newValue = isActive ? undefined : itemValue;
if (!(newValue === undefined && !canByEmpty)) onChange(newValue);
} else {
const newValues = items.map(getWithValueKey).filter((v) => {
if (isActive) {
return values.includes(v) && v !== itemValue;
} else {
return [itemValue, ...values].includes(v);
}
});
onChange(newValues);
}
}
}}
>
{renderItem ? (
renderItem(item, isActive)
) : (
<Flex p={"8px"} alignItems={"center"} justifyContent={"space-between"}>
<Text color={"textGray"}>{String(getWithValueKey(item))}</Text>
{isActive && <CheckIcon size={"16px"} color={"textGray"} />}
</Flex>
)}
</MenuBtnWrap>
);
})}
</MenuList>
)}
</MenuWrap>
);
}
87 changes: 87 additions & 0 deletions src/components/Menu/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import MenuComponent from "./Menu";
import { Box, Flex, Grid } from "../Box";
import { Text } from "../Text";
import React, { useState } from "react";
import { Button } from "../Button";

export default {
title: "Components/Menu",
component: MenuComponent,
argTypes: {},
};

export const Menu = () => {
const numericItems = [1, 2, 3, 4, 5];

const items = [
{ id: 1, name: "First Item" },
{ id: 2, name: "Second Item" },
{ id: 3, name: "Last Item" },
];

const [opened, setOpened] = useState(false);
const [value0, setValue0] = useState(1);
const [value1, setValue1] = useState(2);
return (
<Grid gridTemplateColumns={"repeat(2, 1fr)"} mt={"10px"}>
<Box>
<Text variant={"h5"} mb="20px">
Simple
</Text>
<MenuComponent<(typeof numericItems)[number]>
listWidth={"100%"}
align={"center"}
offsetY={5}
items={numericItems}
value={value0}
onChange={setValue0}
/>
</Box>
<Box>
<Text variant={"h5"} mb="20px">
Reactive (custom activator & items)
</Text>
<Flex alignItems={"center"}>
<MenuComponent<(typeof items)[number]>
align={"center"}
offsetY={5}
opened={opened}
openedChange={setOpened}
items={items}
valueKey={"id"}
value={value1}
onChange={setValue1}
listWidth={"100%"}
activator={
<Text bg={"black"} color={"textGray"} p={"4px"} width={"120px"} height={"26px"}>
{items.find((i) => i.id === value1)?.name}
</Text>
}
renderItem={(item, isActive) => (
<Text bg={"black"} color={isActive ? "red" : "textGray"} p={"8px"}>
{item.name}
</Text>
)}
/>
<Button
width={"90px"}
scale={"small"}
disabled={opened}
onClick={() => {
setOpened(true);
}}
ml={"40px"}
>
Open (reactive)
</Button>
</Flex>
</Box>
<Text color="strokeGray" variant={"h5"} mt="40px">
Overlapping content.
</Text>
<Text color="strokeGray" variant={"h5"} mt="40px">
Overlapping content.
</Text>
</Grid>
);
};
2 changes: 2 additions & 0 deletions src/components/Menu/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as Menu } from "./Menu";
export type { IMenuProps } from "./types";
40 changes: 40 additions & 0 deletions src/components/Menu/styles.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import styled from "styled-components";
import { Flex, Grid } from "../Box";
import { Z_INDEX } from "../../constants";
import { rgba } from "polished";
import { IMenuProps } from "./types";

type AlignProp = Exclude<IMenuProps["align"], undefined>;

const menuAligns: { [key in AlignProp]: string } = {
center: "center",
left: "flex-start",
right: "flex-end",
};

export const MenuWrap = styled(Flex)<{ align: AlignProp }>`
display: inline-flex;
position: relative;
justify-content: ${({ align }) => menuAligns[align]};
`;

export const MenuBtnWrap = styled.button`
outline: none;
background: transparent;
border: none;
cursor: pointer;
padding: 0;
`;

export const MenuList = styled(Grid)<{ offsetX: number; offsetY: number }>`
position: absolute;
top: 100%;
transform: ${({ offsetX, offsetY }) => `translate(${offsetX}px, ${offsetY}px)`};
background: ${({ theme }) => theme.colors.darkBg};
box-shadow: 0 2px 16px -4px ${({ theme }) => rgba(theme.colors.shadowDark, 0.04)};
border: 2px solid ${({ theme: A }) => rgba(A.colors.strokeGray, 0.4)};
border-radius: 12px;
box-sizing: border-box;
z-index: ${Z_INDEX.DROPDOWN};
overflow: hidden;
`;
18 changes: 18 additions & 0 deletions src/components/Menu/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { ReactNode } from "react";

export interface IMenuProps<T = any> {
opened?: boolean;
openedChange?: (value: boolean) => any;
activator?: ReactNode;
listWidth?: string;
align?: "center" | "left" | "right";
offsetX?: number;
offsetY?: number;
items: T[];
renderItem?: (value: T, isActive: boolean) => ReactNode;
value?: any;
valueKey?: keyof T;
onChange?: (value: any) => any;
multiple?: boolean;
canByEmpty?: boolean;
}
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ export * from "./Tabs";
export * from "./Dropdown";
export * from "./Table";
export * from "./Tooltip";
export * from "./Menu";

0 comments on commit eb52aa6

Please sign in to comment.