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
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { style } from '@vanilla-extract/css';
import { vars } from '../../shared/styles/theme.css';
import { typography } from '../../shared/styles';

export const container = style({
position: 'relative',
overflow: 'hidden',
userSelect: 'none',
touchAction: 'none',
cursor: 'grab',

selectors: {
'&:active': {
cursor: 'grabbing',
},
'&:focus-visible': {
outline: `2px solid ${vars.colors.blue60}`,
outlineOffset: '2px',
borderRadius: '8px',
},
},
});

export const containerDisabled = style({
opacity: 0.4,
pointerEvents: 'none',
cursor: 'not-allowed',
});

export const list = style({
display: 'flex',
flexDirection: 'column',
willChange: 'transform',
});

export const item = style([
typography.body.b1,
{
height: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
transition: 'color 0.15s ease',
color: vars.colors.gray20,
},
]);

export const itemSelected = style({
color: vars.colors.black,
});

export const gradientTop = style({
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '88px', // 44px * 2
background: `linear-gradient(to bottom, ${vars.colors.background} 0%, transparent 100%)`,
pointerEvents: 'none',
zIndex: 2,
});

export const gradientBottom = style({
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: '88px', // 44px * 2
background: `linear-gradient(to top, ${vars.colors.background} 0%, transparent 100%)`,
pointerEvents: 'none',
zIndex: 2,
});

export const selectionIndicator = style({
position: 'absolute',
left: 0,
right: 0,
height: '44px',
top: '88px', // 44px * 2
borderTop: `0.5px solid ${vars.colors.gray10}`,
borderBottom: `0.5px solid ${vars.colors.gray10}`,
pointerEvents: 'none',
zIndex: 1,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
import { createElement, useState, type FC } from 'react';
import { WheelPicker } from './WheelPicker';

const hourItems = Array.from({ length: 24 }, (_, i) => ({
label: String(i).padStart(2, '0'),
value: i,
}));

const minuteItems = Array.from({ length: 60 }, (_, i) => ({
label: String(i).padStart(2, '0'),
value: i,
}));

const fruitItems = [
{ label: '사과', value: 'apple' },
{ label: '바나나', value: 'banana' },
{ label: '체리', value: 'cherry' },
{ label: '포도', value: 'grape' },
{ label: '망고', value: 'mango' },
{ label: '오렌지', value: 'orange' },
{ label: '딸기', value: 'strawberry' },
];

const meta = {
title: 'Components/WheelPicker',
component: WheelPicker,
parameters: {
layout: 'centered',
},
decorators: [
(Story: FC) =>
createElement('div', { style: { width: '335px' } }, createElement(Story)),
],
args: {
onChange: fn(),
},
} satisfies Meta<typeof WheelPicker>;

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

export const Default: Story = {
args: {
items: fruitItems,
defaultValue: 'cherry',
},
};

export const Hours: Story = {
args: {
items: hourItems,
defaultValue: 12,
},
};

export const Minutes: Story = {
args: {
items: minuteItems,
defaultValue: 30,
},
};

export const Loop: Story = {
args: {
items: fruitItems,
defaultValue: 'cherry',
loop: true,
},
};

export const Disabled: Story = {
args: {
items: fruitItems,
defaultValue: 'apple',
disabled: true,
},
};

export const FewItems: Story = {
args: {
items: fruitItems.slice(0, 3),
defaultValue: 'banana',
visibleCount: 3,
},
};

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

const TimePicker: FC = () => {
const [amPm, setAmPm] = useState<string | number>('am');
const [hour, setHour] = useState<string | number>(8);
const [minute, setMinute] = useState<string | number>(30);

return createElement(
'div',
{
style: {
display: 'flex',
width: '335px',
},
},
createElement(
'div',
{ style: { flex: 1 } },
createElement(WheelPicker, {
items: amPmItems,
value: amPm,
onChange: setAmPm,
})
),
createElement(
'div',
{ style: { flex: 1 } },
createElement(WheelPicker, {
items: hourItems,
value: hour,
onChange: setHour,
})
),
createElement(
'div',
{ style: { flex: 1 } },
createElement(WheelPicker, {
items: minuteItems,
value: minute,
onChange: setMinute,
})
)
);
};

export const TimePick: StoryObj = {
render: () => createElement(TimePicker),
};
88 changes: 88 additions & 0 deletions packages/design-system/src/components/wheel-picker/WheelPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import clsx from 'clsx';
import type { WheelPickerProps } from './model/types';
import { useWheelPicker } from './model/useWheelPicker';
import { ITEM_HEIGHT } from './model/useScrollAnimation';
import {
container,
containerDisabled,
list,
item,
itemSelected,
gradientTop,
gradientBottom,
selectionIndicator,
} from './WheelPicker.css';
Comment thread
seseoju marked this conversation as resolved.

export function WheelPicker({
items,
value,
defaultValue,
onChange,
visibleCount = 5,
loop = false,
disabled = false,
}: WheelPickerProps) {
const padCount = Math.floor(visibleCount / 2);
const { containerRef, listRef, renderedItems, handlers } = useWheelPicker({
items,
value,
defaultValue,
onChange,
visibleCount,
loop,
disabled,
selectedClassName: itemSelected,
});

return (
<div
ref={containerRef}
className={clsx(container, disabled && containerDisabled)}
style={{ height: `${ITEM_HEIGHT * visibleCount}px` }}
role="listbox"
aria-orientation="vertical"
tabIndex={disabled ? -1 : 0}
{...handlers}
>
Comment on lines +38 to +46
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

🔧 개선 제안

  • 접근성 향상을 위해 aria-labelaria-activedescendant를 추가하는 것이 좋습니다. 현재 선택된 아이템의 ID를 추적하여 스크린 리더가 인식할 수 있게 합니다.
    <div
      ref={containerRef}
      className={clsx(container, disabled && containerDisabled)}
      style={{ height: `${ITEM_HEIGHT * visibleCount}px` }}
      role="listbox"
      aria-label="Wheel Picker"
      aria-orientation="vertical"
      aria-activedescendant={disabled ? undefined : `wheel-picker-item-${selectedIndex}`}
      tabIndex={disabled ? -1 : 0}
      {...handlers}
    >
References
  1. 접근성 준수 (aria, semantic tag) (link)

<div ref={listRef} className={list}>
{!loop &&
Array.from({ length: padCount }).map((_, i) => (
<div
key={`spacer-top-${i}`}
className={item}
aria-hidden
data-ghost="true"
/>
))}
{renderedItems.map(({ item: pickerItem, realIndex, isGhost }, i) => (
<div
key={`${isGhost ? 'ghost' : 'real'}-${i}`}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

❌ 위반 사항

  • [index key 금지] 리스트 렌더링 시 index를 key로 사용하는 것은 금지되어 있습니다. renderedItems 모델에서 고유한 key를 생성하여 전달받는 방식을 권장합니다.
References
  1. index key 금지 (link)

className={item}
data-real-index={String(realIndex)}
data-ghost={isGhost ? 'true' : 'false'}
role="option"
id={isGhost ? undefined : `wheel-picker-item-${realIndex}`}
aria-selected={false}
Comment thread
seseoju marked this conversation as resolved.
aria-hidden={isGhost ? true : undefined}
>
{pickerItem.label}
</div>
))}
{!loop &&
Array.from({ length: padCount }).map((_, i) => (
<div
key={`spacer-bottom-${i}`}
className={item}
aria-hidden
data-ghost="true"
/>
))}
</div>
<div className={gradientTop} aria-hidden />
<div className={gradientBottom} aria-hidden />
<div className={selectionIndicator} aria-hidden />
</div>
);
}

export type { WheelPickerItem, WheelPickerProps } from './model/types';
14 changes: 14 additions & 0 deletions packages/design-system/src/components/wheel-picker/model/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export type WheelPickerItem = {
label: string;
value: string | number;
};

export type WheelPickerProps = {
items: WheelPickerItem[];
value?: string | number;
defaultValue?: string | number;
onChange?: (value: string | number) => void;
visibleCount?: number; // 화면에 보이는 아이템 개수
loop?: boolean;
disabled?: boolean;
};
Loading
Loading