From eb52aa60e6b0a2e0c06b4f735c897f7d500494a4 Mon Sep 17 00:00:00 2001 From: belousovjr Date: Wed, 29 Nov 2023 18:18:40 +0200 Subject: [PATCH] Added Menu component with simple stories view --- src/components/Menu/Menu.tsx | 103 ++++++++++++++++++++++++++ src/components/Menu/index.stories.tsx | 87 ++++++++++++++++++++++ src/components/Menu/index.tsx | 2 + src/components/Menu/styles.tsx | 40 ++++++++++ src/components/Menu/types.ts | 18 +++++ src/components/index.ts | 1 + 6 files changed, 251 insertions(+) create mode 100644 src/components/Menu/Menu.tsx create mode 100644 src/components/Menu/index.stories.tsx create mode 100644 src/components/Menu/index.tsx create mode 100644 src/components/Menu/styles.tsx create mode 100644 src/components/Menu/types.ts diff --git a/src/components/Menu/Menu.tsx b/src/components/Menu/Menu.tsx new file mode 100644 index 0000000..6804ee7 --- /dev/null +++ b/src/components/Menu/Menu.tsx @@ -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({ + activator, + listWidth, + align = "center", + offsetX = 0, + offsetY = 0, + opened, + openedChange = (v: boolean) => {}, + value, + onChange, + renderItem, + items = [], + valueKey, + multiple, + canByEmpty, +}: IMenuProps) { + const [locOpened, setLocOpened] = useState(opened); + + const menuRef = useRef(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 ( + + { + openedChangeHandler(!isOpened); + }} + > + {activator || ( + + Activator + + )} + + {isOpened && ( + + {items.map((item, i) => { + const isActive = values.includes(getWithValueKey(item)); + return ( + { + 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) + ) : ( + + {String(getWithValueKey(item))} + {isActive && } + + )} + + ); + })} + + )} + + ); +} diff --git a/src/components/Menu/index.stories.tsx b/src/components/Menu/index.stories.tsx new file mode 100644 index 0000000..339be3c --- /dev/null +++ b/src/components/Menu/index.stories.tsx @@ -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 ( + + + + Simple + + + listWidth={"100%"} + align={"center"} + offsetY={5} + items={numericItems} + value={value0} + onChange={setValue0} + /> + + + + Reactive (custom activator & items) + + + + align={"center"} + offsetY={5} + opened={opened} + openedChange={setOpened} + items={items} + valueKey={"id"} + value={value1} + onChange={setValue1} + listWidth={"100%"} + activator={ + + {items.find((i) => i.id === value1)?.name} + + } + renderItem={(item, isActive) => ( + + {item.name} + + )} + /> + + + + + Overlapping content. + + + Overlapping content. + + + ); +}; diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx new file mode 100644 index 0000000..17c6402 --- /dev/null +++ b/src/components/Menu/index.tsx @@ -0,0 +1,2 @@ +export { default as Menu } from "./Menu"; +export type { IMenuProps } from "./types"; diff --git a/src/components/Menu/styles.tsx b/src/components/Menu/styles.tsx new file mode 100644 index 0000000..ddddd34 --- /dev/null +++ b/src/components/Menu/styles.tsx @@ -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; + +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; +`; diff --git a/src/components/Menu/types.ts b/src/components/Menu/types.ts new file mode 100644 index 0000000..e422dbd --- /dev/null +++ b/src/components/Menu/types.ts @@ -0,0 +1,18 @@ +import { ReactNode } from "react"; + +export interface IMenuProps { + 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; +} diff --git a/src/components/index.ts b/src/components/index.ts index a6de343..e5bf083 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -12,3 +12,4 @@ export * from "./Tabs"; export * from "./Dropdown"; export * from "./Table"; export * from "./Tooltip"; +export * from "./Menu";