Skip to content

Commit

Permalink
Merge pull request #173 from choco-team/172-fe-타이머
Browse files Browse the repository at this point in the history
[FE] 타이머 기능 개발
  • Loading branch information
nlom0218 authored Mar 7, 2024
2 parents 058ac7e + 3a728db commit b2a1f4e
Show file tree
Hide file tree
Showing 31 changed files with 1,094 additions and 34 deletions.
2 changes: 1 addition & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
"position": "after"
},
{
"pattern": "@Components/*",
"pattern": "@Components/**/*",
"group": "internal",
"position": "after"
},
Expand Down
2 changes: 2 additions & 0 deletions src/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import StudentManagement from '@Pages/StudentManagement';
import StudentInfo from '@Pages/StudentManagement/StudentInfo';
import StudentRegister from '@Pages/StudentManagement/StudentRegister';
import RandomPick from '@Pages/Tools/RandomPick';
import Timer from '@Pages/Tools/Timer';

const router = createBrowserRouter([
{
Expand Down Expand Up @@ -89,6 +90,7 @@ const router = createBrowserRouter([
children: [
{
path: ROUTE_PATH.timer,
element: <Timer />,
},
{
path: ROUTE_PATH.randomPick,
Expand Down
42 changes: 42 additions & 0 deletions src/components/Icon/ArrowIcon/ArrowIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ComponentPropsWithoutRef } from 'react';

type Direction = 'down' | 'up' | 'left' | 'right';

type ArrowIconProps = {
color?: string;
direction: Direction;
};

const DIRECTION: Record<Direction, string> = {
down: 'M11.6667 16.6667L20 25.0001L28.3333 16.6667',
up: 'M28.3333 23.3333L20 15L11.6667 23.3333',
left: 'M23.3333 11.6667L15 20.0001L23.3333 28.3334',
right: 'M16.6667 28.3334L25 20.0001L16.6667 11.6667',
};

const ArrowIcon = ({
color = 'black',
direction,
...rest
}: ArrowIconProps & ComponentPropsWithoutRef<'svg'>) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 40 40"
fill={color}
{...rest}
>
<path
d={DIRECTION[direction]}
stroke={color}
strokeWidth="1.66667"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};

export default ArrowIcon;
56 changes: 56 additions & 0 deletions src/components/Select/Select.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { Meta, StoryObj } from '@storybook/react';

import Select from '.';

type Story = StoryObj<typeof Select>;

/**
* `Select` 컴포넌트는 사용자가 텍스트를 입력하고 편집할 수 있는 컴포넌트입니다.
*/
const meta: Meta<typeof Select> = {
title: 'INPUTS/Select',
component: Select,
};

export default meta;

/**
* `DefaultSelect`는 가징 기본적인 `Select` 스토리입니다.
*/
export const DefaultSelect: Story = {
args: {
label: 'DEFAULT',
options: ['Option1', 'Option2', 'Option3', 'Option4', 'Option5'],
onChangeOption: (selected) => {
console.log(selected);
},
},
};

/**
* `HasDefaultOptionSelect`는 defaultOption이 존재하는 `Select` 스토리입니다.
*/
export const HasDefaultOptionSelect: Story = {
args: {
label: 'Has Default Option',
options: ['Option1', 'Option2', 'Option3', 'Option4', 'Option5'],
defaultOption: 'Option1',
onChangeOption: (selected) => {
console.log(selected);
},
},
};

/**
* `HasPlaceholderSelect`는 placeholder이 존재하는 `Select` 스토리입니다.
*/
export const HasPlaceholderSelect: Story = {
args: {
label: 'Has Placeholder',
options: ['Option1', 'Option2', 'Option3', 'Option4', 'Option5'],
placeholder: '커스튬 가능한 문구입니다.',
onChangeOption: (selected) => {
console.log(selected);
},
},
};
142 changes: 142 additions & 0 deletions src/components/Select/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import {
MouseEventHandler,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';

import useOutsideClick from '@Hooks/useOutsideClick';

import ArrowIcon from '@Components/Icon/ArrowIcon/ArrowIcon';

import * as S from './style';

type SelectProps<Options extends readonly (string | number)[]> = {
label?: string;
placeholder?: string;
defaultOption?: Options[number];
options: Options;
onChangeOption?: (selected: Options[number]) => void;
};

const Select = <Options extends readonly (string | number)[]>({
label,
placeholder = '옵션을 선택해 주세요.',
defaultOption = '',
options,
onChangeOption,
}: SelectProps<Options>) => {
const optionsRef = useRef<HTMLUListElement>(null);

const [selectedOption, setSelectedOption] = useState(defaultOption);
const [isOpenOptions, setIsOptionOptions] = useState(false);

const ref = useOutsideClick<HTMLDivElement>(() => setIsOptionOptions(false));

const [offsetTop, setOffsetTop] = useState(ref?.current?.offsetTop || 0);
const [clientWidth, setClientWidth] = useState(
ref?.current?.clientWidth || 0,
);

const handleClickArrowIcon: MouseEventHandler<SVGSVGElement> = (event) => {
event.preventDefault();

setIsOptionOptions((prev) => !prev);
};

const handleChangeOption = (value: Options[number]) => {
setIsOptionOptions(false);
setSelectedOption(value);

onChangeOption?.(value);
};

const setOptionsPositionWidth = useCallback(() => {
const offsetTop = ref?.current?.offsetTop;
const clientWidth = ref?.current?.clientWidth;

if (!offsetTop || !clientWidth) return;

setOffsetTop(offsetTop);
setClientWidth(clientWidth);
}, [ref]);

useEffect(() => {
window.addEventListener('resize', setOptionsPositionWidth);

return () => window.removeEventListener('resize', setOptionsPositionWidth);
}, [setOptionsPositionWidth]);

useLayoutEffect(() => {
if (!isOpenOptions) return;

setOptionsPositionWidth();
}, [isOpenOptions, setOptionsPositionWidth]);

useEffect(() => {
if (defaultOption) setSelectedOption(defaultOption);
}, [defaultOption]);

// 스크롤 계산
useEffect(() => {
if (!isOpenOptions || !optionsRef?.current) return;

const { scrollHeight, offsetHeight, children } = optionsRef.current;
if (scrollHeight < offsetHeight) return;

const options = Object.values(children) as HTMLLIElement[];

let top = 10;
for (let i = 0; i < options.length; i++) {
const { offsetHeight, innerText } = options[i];
top += offsetHeight + 8;

if (innerText === selectedOption) break;
}

optionsRef.current.scrollTo({
top: top - 160,
});
}, [isOpenOptions, selectedOption]);

return (
<S.Select ref={ref}>
<label>
<S.LabelText>{label}</S.LabelText>
<S.InputLayout $isFocus={isOpenOptions}>
<S.Input
readOnly
value={selectedOption}
onClick={() => setIsOptionOptions((prev) => !prev)}
placeholder={placeholder}
/>
<ArrowIcon
direction={isOpenOptions ? 'up' : 'down'}
onClick={handleClickArrowIcon}
/>
</S.InputLayout>
</label>
{isOpenOptions && (
<S.Options
ref={optionsRef}
top={label ? `${offsetTop + 60}px` : `${offsetTop + 45}px`}
width={`${clientWidth}px`}
>
{options.map((option) => (
<S.Option
key={option}
onClick={() => handleChangeOption(option)}
$isSelected={selectedOption === option}
>
{option}
</S.Option>
))}
</S.Options>
)}
</S.Select>
);
};

export default Select;
Loading

0 comments on commit b2a1f4e

Please sign in to comment.