Skip to content

Commit ff552ec

Browse files
authored
Feat(bds): Slider 컴포넌트 (team-bofit#196)
* feat: slider 초기세팅 * feat: slider 로직 구현 * fix: 파일 오류 수정 * feat: min max label 세팅 * feat: 불필요한 로직 제거 * refactor: 로직 개선 * feat: slider 스토리북 작성 * chore: 빌드 에러 해결, 주석 제거 * chore: home page 롤백 * fix: mypage 임시코드 롤백 * fix: dispatch 장점 살리도록 변경 * fix: 디버깅 코드 제거 * fix: 마이페이지 롤백 * fix: 함수 수정
1 parent ed0e4a2 commit ff552ec

File tree

5 files changed

+420
-0
lines changed

5 files changed

+420
-0
lines changed

packages/bds-ui/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export { default as Modal } from './modal/modal';
1010
export { default as ModalContainer } from './modal/modal-container';
1111
export * from './modal/store/modal-store';
1212
export { default as Navigation } from './navigation/navigation';
13+
export { default as Slider } from './slider/slider';
1314
export { default as Tab } from './tab/tab';
1415
export { default as TextButton } from './text-button/text-button';
1516
export { default as ThemeProvider } from './theme-provider';
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { RefObject, useCallback, useLayoutEffect, useReducer } from 'react';
2+
3+
type SliderAction =
4+
| { type: 'SET_MIN'; payload: number }
5+
| { type: 'SET_MAX'; payload: number }
6+
| { type: 'SET_BOTH'; payload: [number, number] };
7+
8+
const sliderReducer = (
9+
state: [number, number],
10+
action: SliderAction,
11+
): [number, number] => {
12+
switch (action.type) {
13+
case 'SET_MIN':
14+
return [action.payload, state[1]];
15+
case 'SET_MAX':
16+
return [state[0], action.payload];
17+
case 'SET_BOTH':
18+
return action.payload;
19+
default:
20+
return state;
21+
}
22+
};
23+
24+
/**
25+
* useSliderValue 훅은 슬라이더의 값을 관리하는 데 사용됩니다.
26+
* @param value
27+
* @param defaultValue
28+
* @param min
29+
* @param max
30+
*/
31+
32+
export const useSliderValue = (
33+
value?: [number, number],
34+
defaultValue?: [number, number],
35+
min?: number,
36+
max?: number,
37+
) => {
38+
const isControlled = value !== undefined;
39+
const initialValue = defaultValue || [min || 0, max || 100];
40+
41+
const [state, dispatch] = useReducer(sliderReducer, initialValue);
42+
43+
const currentValue = isControlled ? value : state;
44+
45+
const updateValue = useCallback(
46+
(type: 'SET_MIN' | 'SET_MAX', newValue: number) => {
47+
dispatch({
48+
type: type,
49+
payload: newValue,
50+
});
51+
},
52+
[],
53+
);
54+
55+
return {
56+
currentValue,
57+
updateValue,
58+
isControlled,
59+
};
60+
};
61+
62+
/**
63+
* useTrackAnimation 훅은 슬라이더의 트랙 애니메이션을 관리합니다.
64+
* @param ref
65+
* @param minVal
66+
* @param maxVal
67+
* @param min
68+
* @param max
69+
*/
70+
71+
export const useTrackAnimation = (
72+
ref: RefObject<HTMLDivElement | null>,
73+
minVal: number,
74+
maxVal: number,
75+
min: number,
76+
max: number,
77+
) => {
78+
const valueToPercent = useCallback(
79+
(val: number) => ((val - min) / (max - min)) * 100,
80+
[min, max],
81+
);
82+
83+
useLayoutEffect(() => {
84+
if (!ref.current) {
85+
return;
86+
}
87+
88+
const minPercent = valueToPercent(minVal);
89+
const maxPercent = valueToPercent(maxVal);
90+
91+
ref.current.style.left = `${minPercent}%`;
92+
ref.current.style.width = `${maxPercent - minPercent}%`;
93+
}, [minVal, maxVal, valueToPercent, ref]);
94+
};
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { style } from '@vanilla-extract/css';
2+
3+
import { themeVars } from '../../styles';
4+
5+
export const SliderContainer = style({
6+
position: 'relative',
7+
width: '100%',
8+
height: '4rem',
9+
display: 'flex',
10+
alignItems: 'center',
11+
justifyContent: 'center',
12+
});
13+
14+
export const sliderTrack = style({
15+
position: 'absolute',
16+
top: '50%',
17+
left: 0,
18+
right: 0,
19+
height: '0.4rem',
20+
backgroundColor: themeVars.color.gray100,
21+
transform: 'translateY(-50%)',
22+
borderRadius: '2px',
23+
zIndex: themeVars.zIndex.base,
24+
});
25+
26+
export const sliderRange = style({
27+
position: 'absolute',
28+
top: '50%',
29+
height: '0.4rem',
30+
backgroundColor: themeVars.color.primary500,
31+
borderRadius: '2px',
32+
transform: 'translateY(-50%)',
33+
zIndex: themeVars.zIndex.content,
34+
});
35+
36+
export const thumb = style({
37+
pointerEvents: 'none',
38+
position: 'absolute',
39+
width: '100%',
40+
height: '0.4rem',
41+
zIndex: themeVars.zIndex.overlay,
42+
43+
background: 'transparent',
44+
appearance: 'none',
45+
46+
selectors: {
47+
'&::-webkit-slider-thumb': {
48+
appearance: 'none',
49+
pointerEvents: 'auto',
50+
width: '2rem',
51+
height: '2rem',
52+
borderRadius: '20px',
53+
backgroundColor: themeVars.color.primary500,
54+
cursor: 'pointer',
55+
},
56+
'&::-moz-range-thumb': {
57+
pointerEvents: 'auto',
58+
width: '2rem',
59+
height: '2rem',
60+
borderRadius: '20px',
61+
backgroundColor: themeVars.color.primary500,
62+
cursor: 'pointer',
63+
},
64+
},
65+
});
66+
67+
export const thumbMax = style({
68+
zIndex: themeVars.zIndex.overlay,
69+
});
70+
71+
export const sliderLabels = style({
72+
display: 'flex',
73+
justifyContent: 'space-between',
74+
width: '100%',
75+
position: 'absolute',
76+
top: '3.5rem',
77+
});
78+
79+
export const sliderLabel = style({
80+
...themeVars.fontStyles.body1_m_12,
81+
color: themeVars.color.gray600,
82+
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import React, { useState } from 'react';
2+
import type { Meta, StoryObj } from '@storybook/react';
3+
4+
import Slider from './slider';
5+
6+
const meta: Meta<typeof Slider> = {
7+
title: 'common/Slider',
8+
component: Slider,
9+
tags: ['autodocs'],
10+
argTypes: {
11+
onChange: { action: 'changed' },
12+
},
13+
parameters: {
14+
layout: 'centered',
15+
componentSubtitle: 'Range Slider 컴포넌트',
16+
docs: {
17+
description: {
18+
component: `
19+
Slider 컴포넌트는 최소값과 최대값 범위 내에서 두 개의 핸들을 통해 값을 선택할 수 있는 UI 컴포넌트입니다.
20+
21+
- \`min\`: 최소값
22+
- \`max\`: 최대값
23+
- \`defaultValue\`: 초기값 (비제어 방식)
24+
- \`value\`, \`onChange\`: 제어 방식
25+
- \`step\`: 증가 단위
26+
- \`disabled\`: 비활성화 여부
27+
- \`aria-label\`: 접근성을 위한 레이블
28+
29+
Controlled / Uncontrolled 두 방식 모두 지원하며, 반응형 스타일과 접근성(aria-label)도 함께 제공됩니다.
30+
31+
`,
32+
},
33+
},
34+
},
35+
decorators: [
36+
(Story) => (
37+
<div style={{ width: '375px' }}>
38+
<Story />
39+
</div>
40+
),
41+
],
42+
};
43+
44+
export default meta;
45+
46+
type Story = StoryObj<typeof Slider>;
47+
48+
/**
49+
* Uncontrolled version with defaultValue
50+
*
51+
* 이 스토리는 value를 상태로 관리하지 않는 uncontrolled 방식입니다.
52+
* 사용자가 슬라이더 핸들을 조작할 때마다 내부 상태가 자동으로 업데이트되며,
53+
* 외부에서 별도로 value나 onChange를 제어하지 않아도 됩니다.
54+
*
55+
* props 설명:
56+
* - min: 슬라이더의 최소값
57+
* - max: 슬라이더의 최대값
58+
* - defaultValue: 초기 값 (제어되지 않는 상태에서 사용됨)
59+
* - step: 슬라이더 값의 증가 단위
60+
*/
61+
62+
export const Uncontrolled: Story = {
63+
args: {
64+
min: 0,
65+
max: 100,
66+
defaultValue: [20, 80],
67+
step: 1,
68+
},
69+
};
70+
71+
/**
72+
* Uncontrolled version with defaultValue
73+
*
74+
* 이 스토리는 value를 상태로 관리하지 않는 uncontrolled 방식입니다.
75+
* 사용자가 슬라이더 핸들을 조작할 때마다 내부 상태가 자동으로 업데이트되며,
76+
* 외부에서 별도로 value나 onChange를 제어하지 않아도 됩니다.
77+
*
78+
* props 설명:
79+
* - min: 슬라이더의 최소값
80+
* - max: 슬라이더의 최대값
81+
* - defaultValue: 초기 값 (새로고침 시에도 유지됨)
82+
* - step: 슬라이더 값의 증가 단위
83+
* - onChange: 값 변경 시 호출되는 콜백 함수
84+
* - aria-label: 접근성을 위한 레이블
85+
* - value: 현재 슬라이더 값 (제어되는 상태에서 사용됨)
86+
*/
87+
88+
export const Controlled: Story = {
89+
render: (args) => {
90+
const [range, setRange] = useState<[number, number]>([30, 70]);
91+
92+
return (
93+
<Slider
94+
{...args}
95+
value={range}
96+
onChange={(val) => {
97+
setRange(val);
98+
args.onChange?.(val);
99+
}}
100+
/>
101+
);
102+
},
103+
args: {
104+
min: 0,
105+
max: 100,
106+
step: 5,
107+
},
108+
};

0 commit comments

Comments
 (0)