Skip to content

Commit

Permalink
Added dropdown component
Browse files Browse the repository at this point in the history
  • Loading branch information
belousovjr committed Nov 13, 2023
1 parent c19028f commit 784f6a9
Show file tree
Hide file tree
Showing 7 changed files with 270 additions and 2 deletions.
80 changes: 80 additions & 0 deletions src/components/Dropdown/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import DropdownComponent from "./index";
import { useState } from "react";
import { Box, Grid } from "../Box";
import Text from "../Text";

export default {
title: "Components/Dropdown",
component: DropdownComponent,
argTypes: {},
};

export const Dropdown = () => {
const [value, setValue] = useState(1);
const [value2, setValue2] = useState(1);
const [value3, setValue3] = useState(1);
return (
<Grid gridTemplateColumns={"repeat(3, 1fr)"} mt={"10px"}>
<Box>
<Text variant={"h5"} mb="20px">
Simple
</Text>
<DropdownComponent
items={[
{ value: 1, title: "1D" },
{ value: 2, title: "1W" },
{ value: 3, title: "1M" },
{ value: 4, title: "1Y" },
]}
value={value}
onChange={setValue}
/>
</Box>
<Box>
<Text variant={"h5"} mb="20px">
With long title
</Text>
<DropdownComponent
items={[
{ value: 1, title: "Short" },
{ value: 2, title: "Short" },
{ value: 3, title: "Looooooooooooooooong" },
{ value: 4, title: "Short" },
]}
value={value2}
onChange={setValue2}
/>
</Box>
<Box>
<Text variant={"h5"} mb="20px">
With overflow
</Text>
<DropdownComponent
items={[
{ value: 1, title: "Item 1" },
{ value: 2, title: "Item 2" },
{ value: 3, title: "Item 3" },
{ value: 4, title: "Item 4" },
{ value: 5, title: "Item 5" },
{ value: 6, title: "Item 6" },
{ value: 7, title: "Item 7" },
{ value: 8, title: "Item 8" },
{ value: 9, title: "Item 9" },
{ value: 10, title: "Item 10" },
]}
value={value3}
onChange={setValue3}
/>
</Box>
<Text color="strokeGray" variant={"h5"} mt="40px">
Overlapping content.
</Text>
<Text color="strokeGray" variant={"h5"} mt="40px">
Overlapping content.
</Text>
<Text color="strokeGray" variant={"h5"} mt="40px">
Overlapping content.
</Text>
</Grid>
);
};
146 changes: 146 additions & 0 deletions src/components/Dropdown/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { Box, Grid } from "../Box";
import styled from "styled-components";
import { useEffect, useMemo, useRef, useState } from "react";
import { rgba } from "polished";
import { useOnClickOutside } from "./useOnClickOutside";
import { ArrowDownIcon } from "../Svg";

type DropdownItemValue = string | number;
interface IProps {
items: { title?: string; value?: DropdownItemValue }[];
value?: DropdownItemValue;
onChange?: (value: any) => void;
}

const DropdownWrap = styled(Grid)<{ maxHeight: number; opened?: boolean }>`
width: max-content;
max-height: ${({ maxHeight }) => maxHeight}px;
padding: 0 0 4px;
overflow: hidden;
box-sizing: border-box;
background: ${({ theme, opened }) =>
!opened ? rgba(theme.colors.primaryDefault, 0.08) : rgba(theme.colors.strokeGray, 0.16)};
outline: 0.5px solid ${({ theme }) => rgba(theme.colors.strokeGray, 0.2)};
outline-offset: -0.5px;
border-radius: 12px;
backdrop-filter: blur(10px);
& svg {
transform: rotate(${({ opened }) => (opened ? "180deg" : "0")});
stroke: ${({ theme, opened }) => (!opened ? theme.colors.textGray : theme.colors.white)};
transition: all 0.15s;
}
&:hover {
background: ${({ theme }) => rgba(theme.colors.strokeGray, 0.16)};
& svg {
stroke: ${({ theme }) => theme.colors.white};
}
}
transition: max-height 0.15s, background 0.2s;
`;

const DropdownItemWrap = styled.button<{ active?: boolean }>`
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 40px 0 20px;
height: 26px;
border: none;
outline: none;
font-size: 14px;
font-weight: 400;
line-height: 18px;
cursor: pointer;
color: ${({ theme, active }) => (active ? theme.colors.primaryDefault : theme.colors.textGray)};
background: transparent;
&:hover,
&:focus {
color: ${({ theme }) => theme.colors.primaryDefault};
}
transition: color 0.15s;
`;

const DropdownActivatorWrap = styled(DropdownItemWrap)`
height: 34px;
padding: 0 20px;
`;
export default function Dropdown({ items, value, onChange }: IProps) {
const activeItem = useMemo(() => items.find((v) => v.value === value), [items, value]);
const minHeight = 34;
const [maxHeight, setMaxHeight] = useState(minHeight);

const [opened, setOpened] = useState(false);

const listRef = useRef<HTMLDivElement>(null);

useOnClickOutside(listRef, () => {
setOpened(false);
});

useEffect(() => {
if (listRef.current) {
setMaxHeight(listRef.current.scrollHeight);
}
}, [items]);

const height = useMemo(() => (opened ? maxHeight : minHeight), [opened, maxHeight]);

return (
<Box height={`${minHeight}px`} overflow={"visible"} position={"relative"} zIndex={100}>
<DropdownWrap
ref={listRef}
maxHeight={height}
onFocus={() => {
setOpened(true);
listRef.current?.scrollTo(0, 0); //autoscroll bug fix
}}
onBlur={() => {
setTimeout(() => {
if (document.activeElement) {
const el = document.activeElement as any;
//close dropdown if new active element is interactive element from outside
if (el.tabIndex !== -1 && !listRef.current?.contains(el)) {
setOpened(false);
}
}
}, 0);
}}
opened={opened}
>
<DropdownActivatorWrap
onMouseDown={() => {
setTimeout(() => {
setOpened(!opened);
}, 0);
}}
active={true}
tabIndex={-1}
>
<span>{activeItem?.title || activeItem?.value || "-"}</span>
<ArrowDownIcon size={"16px"} />
</DropdownActivatorWrap>

<Grid maxHeight={200} overflowY={"auto"} gridGap={"4px"} pt={"6px"}>
{items.map((item) => (
<DropdownItemWrap
key={item.value}
onClick={() => {
if (onChange) onChange(item.value);
setOpened(false);
}}
active={item.value === value}
>
{item.title || item.value}
</DropdownItemWrap>
))}
</Grid>
</DropdownWrap>
</Box>
);
}
28 changes: 28 additions & 0 deletions src/components/Dropdown/useOnClickOutside.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { RefObject, useEffect, useRef } from 'react';

export function useOnClickOutside<T extends HTMLElement>(node: RefObject<T | undefined>, handler: undefined | (() => void), ignoredNodes: Array<RefObject<T | undefined>> = []) {
const handlerRef = useRef<undefined | (() => void)>(handler);

useEffect(() => {
handlerRef.current = handler;
}, [handler]);

useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const nodeClicked = node.current?.contains(e.target as Node);
const ignoredNodeClicked = ignoredNodes.reduce((reducer, val) => reducer || !!val.current?.contains(e.target as Node), false);

if ((nodeClicked || ignoredNodeClicked) ?? false) {
return;
}

if (handlerRef.current) handlerRef.current();
};

document.addEventListener('mousedown', handleClickOutside);

return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [node, ignoredNodes]);
}
11 changes: 11 additions & 0 deletions src/components/Svg/Icons/ArrowDownIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React from "react";
import Svg from "../Svg";
import { SvgProps } from "../types";

const Icon: React.FC<React.PropsWithChildren<SvgProps>> = (props) => (
<Svg viewBox="0 0 16 16" {...props} fill="none">
<path d="M13 6L8 11L3 6" strokeLinecap="round" strokeLinejoin="round" />
</Svg>
);

export default Icon;
1 change: 1 addition & 0 deletions src/components/Svg/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { default as ArrowLeftIcon } from "./Icons/ArrowLeftIcon";
export { default as QuestionIcon } from "./Icons/QuestionIcon";
export { default as InfoIcon } from "./Icons/InfoIcon";
export { default as ArrowDownIcon } from "./Icons/ArrowDownIcon";
5 changes: 3 additions & 2 deletions src/components/Tabs/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import TabsComponent from "./index";
import { useState } from "react";
import { Box } from "../Box";

export default {
title: "Components/Tabs",
Expand All @@ -10,7 +11,7 @@ export default {
export const Tabs = () => {
const [tab, setTab] = useState(1);
return (
<div>
<Box>
<TabsComponent
tabs={[
{ value: 1, title: "First" },
Expand All @@ -20,6 +21,6 @@ export const Tabs = () => {
value={tab}
onChange={setTab}
/>
</div>
</Box>
);
};
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export * from "./Toggle";
export * from "./AppCheckbox";
export * from "./AppRadioBtn";
export * from "./Tabs";
export * from "./Dropdown";

0 comments on commit 784f6a9

Please sign in to comment.