diff --git a/.storybook/select.stories.tsx b/.storybook/select.stories.tsx new file mode 100644 index 0000000..0365c52 --- /dev/null +++ b/.storybook/select.stories.tsx @@ -0,0 +1,52 @@ +import { ThemeProvider } from 'styled-components'; +import { Select, SelectChild } from '../src/index'; +import { theme } from '../src/theme'; +import { GlobalStyles } from '../src/globalStyles'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; + +const meta = { + component: Select, + decorators: [ + (Story) => ( +
+ + + + +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + value: 'option1', + $onChange: (value) => {}, + }, + render: (args) => ( + + ), +}; + +export const WithLabel: Story = { + args: { + value: 'option1', + $onChange: (value) => {}, + $labelContent: 'Pick option...', + }, + render: (args) => ( + + ), +}; diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx new file mode 100644 index 0000000..88a387f --- /dev/null +++ b/src/components/Select/Select.tsx @@ -0,0 +1,94 @@ +import { + SelectButtonComponent, + SelectButtonLabelComponent, + SelectListComponent, +} from './styles.js'; +import React, { + Children, + cloneElement, + isValidElement, + useEffect, + useRef, + useState, + type ReactElement, +} from 'react'; +import type { SelectChildProps, SelectProps } from './types.js'; +import { KeyboardArrowDown, KeyboardArrowUp } from '@mui/icons-material'; + +export const Select = ({ + $fullWidth = false, + value, + $onChange, + children, + $labelContent, +}: SelectProps) => { + const [isOpen, setIsOpen] = useState(false); + + const parentComponentRef = useRef(null); + + const displayValue = React.useMemo(() => { + // Function for memorizing display value of currently selected child + return ( + Object.values(children as object).find( + (child) => child.props.value === value, + ).props.children || 'N/F' + ); + }, [value, children]); + + const handleOptionClick = (optionValue: string) => { + $onChange(optionValue); + setIsOpen(false); + }; + + const handleUnfocus = (event: PointerEvent) => { + if (!parentComponentRef.current?.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + useEffect(() => { + if (parentComponentRef.current === null) return; + setTimeout(() => { + document.addEventListener('click', handleUnfocus); + }, 0); + + return () => document.removeEventListener('click', handleUnfocus); + }, [isOpen]); + + return ( +
+ {$labelContent !== undefined && ( + {$labelContent} + )} + + setIsOpen(!isOpen)}> + {displayValue} + {isOpen ? ( + + ) : ( + + )} + + {isOpen && ( + + {Children.map(children, (child) => { + if (!isValidElement(child)) return child; + + return cloneElement(child as ReactElement, { + $CURRENT_VALUE: value, + $HANDLE_CLICK: handleOptionClick, + }); + })} + + )} +
+ ); +}; diff --git a/src/components/Select/SelectChild.tsx b/src/components/Select/SelectChild.tsx new file mode 100644 index 0000000..cdee80f --- /dev/null +++ b/src/components/Select/SelectChild.tsx @@ -0,0 +1,26 @@ +import { SelectChildWrapper } from './styles.js'; +import type { SelectChildProps } from './types.js'; +import React from 'react'; + +export const SelectChild = ({ + children, + $CURRENT_VALUE, + $HANDLE_CLICK, + value, +}: SelectChildProps) => { + const handleClick = () => { + if ($HANDLE_CLICK === undefined) + throw new Error('$ON_CHANGE prop is undefined'); + + $HANDLE_CLICK(value); + }; + + return ( + + {children} + + ); +}; diff --git a/src/components/Select/styles.ts b/src/components/Select/styles.ts new file mode 100644 index 0000000..1fbf864 --- /dev/null +++ b/src/components/Select/styles.ts @@ -0,0 +1,67 @@ +import { styled } from 'styled-components'; +import { theme } from '../../theme.js'; + +export const SelectButtonComponent = styled.button` + display: flex; + justify-content: space-between; + align-items: center; + + background-color: ${theme.colors.main}; + border: 1px solid ${theme.colors.lightGray}; + padding: 4px 8px; + border-radius: 6px; + + &:focus { + color: ${theme.colors.primary}; + border: 1px solid ${theme.colors.primary}; + } + + width: 100%; +`; + +export const SelectButtonLabelComponent = styled.label` + width: 100%; + font-size: 10pt; + padding: 8px; + color: ${theme.colors.lightGray}; + + &:has(+ button:focus) { + color: ${theme.colors.primary}; + text-decoration: underline; + } +`; + +export const SelectListComponent = styled.div` + display: flex; + flex-direction: column; + align-items: center; + position: absolute; + top: calc(100% + 5px); + left: 0; + width: 100%; + outline: 1px solid ${theme.colors.lightGray}; + border-radius: 6px; + + & > *:not(:last-child) { + border-bottom: 1px solid ${theme.colors.lightGray}; + } +`; + +export const SelectChildWrapper = styled.button<{ $isCurrentValue: boolean }>` + display: flex; + align-items: center; + justify-content: start; + width: 100%; + background-color: ${theme.colors.main}; + border: none; + padding: 8px; + cursor: pointer; + + ${({ $isCurrentValue }) => + $isCurrentValue && + `border-color: ${theme.colors.primary}; color: ${theme.colors.primary}`}; + + &:hover { + background-color: ${theme.colors.mainDimmed}; + } +`; diff --git a/src/components/Select/types.ts b/src/components/Select/types.ts new file mode 100644 index 0000000..a4e445b --- /dev/null +++ b/src/components/Select/types.ts @@ -0,0 +1,14 @@ +import type { ComponentPropsWithoutRef, ComponentPropsWithRef } from 'react'; + +export interface SelectProps extends ComponentPropsWithRef<'select'> { + $fullWidth?: boolean; + $labelContent?: string; + value: string; + $onChange: (value: string) => void; +} + +export interface SelectChildProps extends ComponentPropsWithRef<'button'> { + value: string; + $HANDLE_CLICK?: (value: string) => void; + $CURRENT_VALUE?: string; +} diff --git a/src/index.tsx b/src/index.tsx index 8964e4a..02047d1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,3 +1,5 @@ export { Button } from './components/Button/Button.js'; export { Link } from './components/Link/Link.js'; export { Input } from './components/Input/Input.js'; +export { Select } from './components/Select/Select.js'; +export { SelectChild } from './components/Select/SelectChild.js';