Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .storybook/select.stories.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<div style={{ width: '100%' }}>
<ThemeProvider theme={theme}>
<GlobalStyles />
<Story />
</ThemeProvider>
</div>
),
],
} satisfies Meta<typeof Select>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {
value: 'option1',
$onChange: (value) => {},
},
render: (args) => (
<Select {...args}>
<SelectChild value="option1">Option 1</SelectChild>
<SelectChild value="option2">Option 2</SelectChild>
<SelectChild value="option3">Option 3</SelectChild>
</Select>
),
};

export const WithLabel: Story = {
args: {
value: 'option1',
$onChange: (value) => {},
$labelContent: 'Pick option...',
},
render: (args) => (
<Select {...args}>
<SelectChild value="option1">Option 1</SelectChild>
<SelectChild value="option2">Option 2</SelectChild>
<SelectChild value="option3">Option 3</SelectChild>
</Select>
),
};
94 changes: 94 additions & 0 deletions src/components/Select/Select.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>(false);

const parentComponentRef = useRef<HTMLDivElement>(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 (
<div
ref={parentComponentRef}
style={{
display: 'flex',
flexDirection: 'column',
position: 'relative',
width: $fullWidth ? '100%' : 'max-content',
}}
>
{$labelContent !== undefined && (
<SelectButtonLabelComponent>{$labelContent}</SelectButtonLabelComponent>
)}

<SelectButtonComponent onClick={() => setIsOpen(!isOpen)}>
{displayValue}
{isOpen ? (
<KeyboardArrowUp style={{ marginLeft: '5px' }} />
) : (
<KeyboardArrowDown style={{ marginLeft: '5px' }} />
)}
</SelectButtonComponent>
{isOpen && (
<SelectListComponent>
{Children.map(children, (child) => {
if (!isValidElement(child)) return child;

return cloneElement(child as ReactElement<SelectChildProps>, {
$CURRENT_VALUE: value,
$HANDLE_CLICK: handleOptionClick,
});
})}
</SelectListComponent>
)}
</div>
);
};
26 changes: 26 additions & 0 deletions src/components/Select/SelectChild.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SelectChildWrapper
onClick={handleClick}
$isCurrentValue={$CURRENT_VALUE === value}
>
{children}
</SelectChildWrapper>
);
};
67 changes: 67 additions & 0 deletions src/components/Select/styles.ts
Original file line number Diff line number Diff line change
@@ -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};
}
`;
14 changes: 14 additions & 0 deletions src/components/Select/types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Loading