Skip to content

Commit

Permalink
Merge pull request #14 from yourssu/feat/picker
Browse files Browse the repository at this point in the history
feat: <Picker> 구현
  • Loading branch information
Hanna922 authored Nov 21, 2023
2 parents e969e92 + 78a1a51 commit 92c8d06
Show file tree
Hide file tree
Showing 12 changed files with 440 additions and 3 deletions.
132 changes: 132 additions & 0 deletions src/components/Picker/Picker.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { useState } from 'react';

import { Meta, StoryObj } from '@storybook/react';

import { Picker } from './Picker';

import { PickerColumn } from '.';

const meta: Meta<typeof Picker> = {
title: 'Atoms/Picker',
component: Picker,
parameters: {
layout: 'centered',
},
};

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

const options = [
{
label: '2022',
value: 'v2022',
},
{
label: '2023',
value: 'v2023',
},
{
label: '2024',
value: 'v2024',
},
{
label: '2025',
value: 'v2025',
},
{
label: '2026',
value: 'v2026',
},
{
label: '2027',
value: 'v2027',
},
{
label: '2028',
value: 'v2028',
},
{
label: '2029',
value: 'v2029',
},
{
label: '2030',
value: 'v2030',
},
];

const Example = () => {
const [value, setValue] = useState('v2022');
const [value2, setValue2] = useState('v2022');
const [value3, setValue3] = useState('v2022');

return (
<Picker>
<PickerColumn options={options} value={value} onChange={(e) => setValue(e.target.value)} />
<PickerColumn options={options} value={value2} onChange={(e) => setValue2(e.target.value)} />
<PickerColumn options={options} value={value3} onChange={(e) => setValue3(e.target.value)} />
</Picker>
);
};

export const Primary: Story = {
render: Example,
};

const DateExample = () => {
const [amPm, setAmPm] = useState('am');
const [hour, setHour] = useState('');
const [minute, setMinute] = useState('');

const amPms = [
{
label: '오전',
value: 'am',
},
{
label: '오후',
value: 'pm',
},
];

const hours = Array.from({ length: 12 }, (_, i) => {
const value = i + 1;
return {
label: `${value}`,
value: `${value}`,
};
});

const minutes = Array.from({ length: 12 }, (_, i) => {
const value = `${i * 5}`.padStart(2, '0');
return {
label: `${value}`,
value: `${value}`,
};
});

return (
<Picker>
<PickerColumn options={amPms} value={amPm} onChange={(e) => setAmPm(e.target.value)} />
<PickerColumn options={hours} value={hour} onChange={(e) => setHour(e.target.value)} />
<PickerColumn options={minutes} value={minute} onChange={(e) => setMinute(e.target.value)} />
</Picker>
);
};

export const Date: Story = {
render: DateExample,
};

export const WithBackground: Story = {
render: () => (
<div style={{ background: '#f0f0f0', width: '500px' }}>
<Picker>
<PickerColumn options={options} value={'v2022'} onChange={() => {}} />
<PickerColumn options={options} value={'v2022'} onChange={() => {}} />
<PickerColumn options={options} value={'v2022'} onChange={() => {}} />
</Picker>
</div>
),
};
39 changes: 39 additions & 0 deletions src/components/Picker/Picker.style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { styled } from 'styled-components';

export const StyledPicker = styled.div`
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 224px; // 32px * 7
overflow: hidden;
padding: 16px 0px;
&::before {
height: 96px;
width: 100%;
content: '';
position: absolute;
top: 0px;
left: 0;
background-color: ${({ theme }) => theme.color.dimThickBright};
border-bottom: 1px solid ${({ theme }) => theme.color.borderNormal};
user-select: none;
pointer-events: none;
}
&::after {
height: 96px;
width: 100%;
content: '';
position: absolute;
bottom: 0px;
left: 0;
background-color: ${({ theme }) => theme.color.dimThickBright};
border-top: 1px solid ${({ theme }) => theme.color.borderNormal};
user-select: none;
pointer-events: none;
}
`;
13 changes: 13 additions & 0 deletions src/components/Picker/Picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { forwardRef } from 'react';

import { StyledPicker } from './Picker.style';
import { PickerProps } from './Picker.type';

export const Picker = forwardRef<HTMLDivElement, PickerProps>(({ children, ...props }, ref) => {
return (
<StyledPicker ref={ref} {...props}>
{children}
</StyledPicker>
);
});
Picker.displayName = 'Picker';
12 changes: 12 additions & 0 deletions src/components/Picker/Picker.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import React from 'react';

export interface PickerProps extends React.ComponentPropsWithRef<'div'> {}

export type PickerColumnOption = {
value: string;
label: string;
};
export type PickerColumnProps = {
options: PickerColumnOption[];
value: string;
} & Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'children'>;
28 changes: 28 additions & 0 deletions src/components/Picker/PickerColumn.style.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { styled } from 'styled-components';

export const StyledPickerColumnContainer = styled.div`
width: fit-content;
height: 224px;
padding: 96px 0;
display: flex;
flex-direction: column;
overflow: auto;
scrollbar-width: 0px;
&::-webkit-scrollbar {
display: none;
}
`;

export const StyledPickerColumnOption = styled.div`
flex: 1;
height: 32px;
min-height: 32px;
padding: 4px 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
cursor: pointer;
user-select: none;
color: ${({ theme }) => theme.color.textPrimary};
`;
162 changes: 162 additions & 0 deletions src/components/Picker/PickerColumn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { forwardRef, useEffect, useRef, useState } from 'react';

import { useDOMRef } from '@/hooks/useDOMRef/useDOMRef';

import { PickerColumnOption, PickerColumnProps } from './Picker.type';
import { StyledPickerColumnContainer, StyledPickerColumnOption } from './PickerColumn.style';

export const PickerColumn = forwardRef<HTMLSelectElement, PickerColumnProps>(
({ options, value, ...props }, ref) => {
const domRef = useDOMRef(ref);

const containerRef = useRef<HTMLDivElement>(null);
const optionRefs = useRef<Record<(typeof options)[number]['value'], HTMLDivElement>>({});

useEffect(() => {
const option = options.find((o) => o.value === value);
if (!option) return;
containerRef.current!.scrollTop = optionRefs.current[option.value].offsetTop - 96;
}, [value, options]);

const change = (option: PickerColumnOption) => {
domRef.current!.value = option.value;
domRef.current!.dispatchEvent(
new Event('change', {
bubbles: true,
cancelable: true,
})
);
containerRef.current!.scrollTop = options.findIndex((o) => o.value === option.value) * 32;
};

const [isIgnoreScroll, setIgnoreScroll] = useState(false);
const ignoreChangeTimer = useRef<NodeJS.Timeout>();

const changeByPosition = () => {
const top = containerRef.current!.scrollTop;
const option = options[Math.round(top / 32)];
change(option);
};

// 모바일 터치 지원
const onTouchStart = () => {
setIgnoreScroll(true);
};
const onTouchEnd = () => {
setIgnoreScroll(false);
setTimeout(() => changeByPosition(), 0);
};

// PC 드래그 스크롤
const isMouseDown = useRef(false);
const isDragging = useRef(false);
const firstPosY = useRef<number>();
const firstMouseY = useRef<number>();
const isIgnoreClickAfterDrag = useRef(false); // 드래그 종료(mouseUp)과 클릭(click)이 중복 동작하는 것을 방지

const onMouseDown = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
setIgnoreScroll(true);
isDragging.current = false;
isMouseDown.current = true;
firstPosY.current = containerRef.current!.scrollTop;
firstMouseY.current = e.clientY;
};

useEffect(() => {
if (!containerRef.current) return;

const onMouseMove = (e: MouseEvent) => {
if (!isMouseDown.current) return;
const diff = e.clientY - firstMouseY.current!;
containerRef.current!.scrollTop = firstPosY.current! - diff;

if (Math.abs(diff) > 5) {
isDragging.current = true;
isIgnoreClickAfterDrag.current = true;
}
};

const onMouseUpOrLeave = () => {
if (!isMouseDown.current) return;
isMouseDown.current = false;
setIgnoreScroll(false);

if (!isDragging.current) return;
setTimeout(() => {
changeByPosition();
}, 0);
};

window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUpOrLeave);
window.addEventListener('mouseleave', onMouseUpOrLeave);
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUpOrLeave);
window.removeEventListener('mouseleave', onMouseUpOrLeave);
};
}, []);

const onScroll = () => {
clearTimeout(ignoreChangeTimer.current!);
ignoreChangeTimer.current = setTimeout(() => {
if (isIgnoreScroll) return;
changeByPosition();
}, 100);
};

const optionClick = (option: PickerColumnOption) => {
if (isIgnoreScroll) return;
if (isIgnoreClickAfterDrag.current) {
isIgnoreClickAfterDrag.current = false;
return;
}
change(option);
};

return (
<>
<StyledPickerColumnContainer
ref={containerRef}
onScroll={onScroll}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchEnd}
onMouseDown={onMouseDown}
style={{
scrollBehavior: isIgnoreScroll ? 'auto' : 'smooth',
}}
>
{options.map((option) => {
return (
<StyledPickerColumnOption
key={option.value}
ref={(el) => (optionRefs.current[option.value] = el as HTMLDivElement)}
onClick={() => optionClick(option)}
>
{option.label}
</StyledPickerColumnOption>
);
})}
</StyledPickerColumnContainer>
<select
ref={domRef}
style={{
display: 'none',
}}
value={value}
{...props}
>
{options.map((option) => {
return (
<option key={option.value} value={option.value}>
{option.label}
</option>
);
})}
</select>
</>
);
}
);
PickerColumn.displayName = 'PickerColumn';
5 changes: 5 additions & 0 deletions src/components/Picker/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export { PickerColumn } from './PickerColumn';
export type { PickerColumnProps } from './Picker.type';

export { Picker } from './Picker';
export type { PickerProps } from './Picker.type';
Loading

0 comments on commit 92c8d06

Please sign in to comment.