Skip to content

Commit 8a1030f

Browse files
authored
Merge pull request #38 from geulDa/feat/#34/date-picker-components
✨Feat: date picker components 구현
2 parents 5ca327d + b33775a commit 8a1030f

File tree

13 files changed

+1118
-22
lines changed

13 files changed

+1118
-22
lines changed

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@
3636
"svg-sprite-loader": "^6.0.11",
3737
"tailwindcss": "4.1.14",
3838
"typescript": "^5",
39-
"@radix-ui/react-progress": "^1.1.7"
39+
"@radix-ui/react-progress": "^1.1.7",
40+
"@radix-ui/react-popover": "^1.1.15",
41+
"date-fns": "^4.1.0",
42+
"react-day-picker": "^9.11.1",
43+
"@radix-ui/react-slot": "^1.2.3",
44+
"lucide-react": "^0.540.0"
4045
}
4146
}

pnpm-lock.yaml

Lines changed: 532 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/pages/index.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { Icon } from '@/shared/icons';
2-
import StampBoard from './main/components/stampBoard/StampBoard';
32

43
export default function Home() {
54
return (
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { CustomDropdown } from './CustomDropdown';
2+
import type { DropdownProps } from 'react-day-picker';
3+
4+
export function CalendarDropdownAdapter({
5+
value,
6+
onChange,
7+
options,
8+
'aria-label': ariaLabel,
9+
}: DropdownProps) {
10+
return (
11+
<CustomDropdown
12+
value={value as number}
13+
options={options ?? []}
14+
aria-label={ariaLabel}
15+
onChange={(next) => {
16+
const evt = {
17+
target: { value: String(next) },
18+
} as unknown as React.ChangeEvent<HTMLSelectElement>;
19+
onChange?.(evt);
20+
}}
21+
/>
22+
);
23+
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { cn } from '@/shared/lib';
2+
import { useCallback, useEffect, useRef, useState } from 'react';
3+
import type { DropdownOption } from 'react-day-picker';
4+
5+
export interface CustomDropdownProps {
6+
value?: number;
7+
onChange: (next: number) => void;
8+
options: DropdownOption[];
9+
'aria-label'?: string;
10+
className?: string;
11+
}
12+
13+
export function CustomDropdown({
14+
value,
15+
onChange,
16+
options,
17+
'aria-label': ariaLabel,
18+
className,
19+
}: CustomDropdownProps) {
20+
const [open, setOpen] = useState(false);
21+
const buttonRef = useRef<HTMLButtonElement>(null);
22+
const listRef = useRef<HTMLUListElement>(null);
23+
24+
const calcSelectedIndex = useCallback(() => {
25+
const i = options.findIndex((o) => o.value === value);
26+
return i >= 0 ? i : 0;
27+
}, [options, value]);
28+
29+
const [activeIndex, setActiveIndex] = useState<number>(calcSelectedIndex());
30+
31+
useEffect(() => {
32+
setActiveIndex(calcSelectedIndex());
33+
}, [calcSelectedIndex]);
34+
35+
const label =
36+
options.find((o) => o.value === value)?.label ?? options[0]?.label ?? '';
37+
38+
useEffect(() => {
39+
if (!open) return;
40+
41+
const handler = (e: Event) => {
42+
const t = (e as Event).target as Node | null;
43+
if (!t) return;
44+
if (!buttonRef.current?.contains(t) && !listRef.current?.contains(t)) {
45+
setOpen(false);
46+
}
47+
};
48+
49+
const usePointer =
50+
typeof window !== 'undefined' && 'PointerEvent' in window;
51+
52+
if (usePointer) {
53+
document.addEventListener('pointerdown', handler as EventListener, true);
54+
return () =>
55+
document.removeEventListener(
56+
'pointerdown',
57+
handler as EventListener,
58+
true,
59+
);
60+
} else {
61+
document.addEventListener('mousedown', handler as EventListener, true);
62+
document.addEventListener('touchstart', handler as EventListener, true);
63+
return () => {
64+
document.removeEventListener(
65+
'mousedown',
66+
handler as EventListener,
67+
true,
68+
);
69+
document.removeEventListener(
70+
'touchstart',
71+
handler as EventListener,
72+
true,
73+
);
74+
};
75+
}
76+
}, [open]);
77+
78+
const onKeyDown = (e: React.KeyboardEvent) => {
79+
if (
80+
!open &&
81+
(e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === ' ')
82+
) {
83+
e.preventDefault();
84+
setOpen(true);
85+
return;
86+
}
87+
if (!open) return;
88+
89+
if (e.key === 'Escape') {
90+
setOpen(false);
91+
return;
92+
}
93+
94+
if (e.key === 'ArrowDown') {
95+
e.preventDefault();
96+
let i = activeIndex + 1;
97+
while (i < options.length && options[i].disabled) i++;
98+
setActiveIndex(Math.min(i, options.length - 1));
99+
} else if (e.key === 'ArrowUp') {
100+
e.preventDefault();
101+
let i = activeIndex - 1;
102+
while (i >= 0 && options[i].disabled) i--;
103+
setActiveIndex(Math.max(i, 0));
104+
} else if (e.key === 'Enter') {
105+
e.preventDefault();
106+
const opt = options[activeIndex];
107+
if (opt && !opt.disabled) {
108+
onChange(opt.value);
109+
setOpen(false);
110+
}
111+
}
112+
};
113+
114+
return (
115+
<div className={cn('relative inline-block', className)}>
116+
<button
117+
ref={buttonRef}
118+
type='button'
119+
aria-haspopup='listbox'
120+
aria-expanded={open}
121+
aria-label={ariaLabel}
122+
onClick={() => setOpen((v) => !v)}
123+
onKeyDown={onKeyDown}
124+
className={cn(
125+
'h-[32px] rounded-[12px] bg-pink-50 border border-pink-100',
126+
'px-3 pr-8 text-label-lg text-pink-800 inline-flex items-center gap-1',
127+
'focus:outline-none focus:ring-2 focus:ring-pink-200',
128+
)}
129+
>
130+
{label}
131+
</button>
132+
133+
{open && (
134+
<ul
135+
ref={listRef}
136+
role='listbox'
137+
tabIndex={-1}
138+
aria-label={ariaLabel}
139+
className={cn(
140+
'absolute z-50 mt-2 w-[14rem] max-h-[20rem] overflow-auto',
141+
'rounded-[12px] border border-pink-200 bg-white p-1 shadow-xl',
142+
)}
143+
>
144+
{options.map((opt, idx) => {
145+
const selected = opt.value === value;
146+
const highlighted = idx === activeIndex;
147+
148+
return (
149+
<li
150+
key={opt.value}
151+
role='option'
152+
aria-selected={selected}
153+
aria-disabled={opt.disabled || undefined}
154+
onMouseEnter={() => setActiveIndex(idx)}
155+
onPointerDown={(e) => e.preventDefault()}
156+
onClick={() => {
157+
if (!opt.disabled) {
158+
onChange(opt.value);
159+
setOpen(false);
160+
}
161+
}}
162+
className={cn(
163+
'relative cursor-pointer select-none rounded-[8px] px-3 py-2 text-body-md',
164+
opt.disabled && 'opacity-40 pointer-events-none',
165+
highlighted && 'bg-pink-50',
166+
selected && 'text-pink-800 font-medium',
167+
)}
168+
>
169+
{opt.label}
170+
</li>
171+
);
172+
})}
173+
</ul>
174+
)}
175+
</div>
176+
);
177+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
'use client';
2+
import { Calendar } from './calendar';
3+
import { Button } from './button';
4+
import { Popover, PopoverContent, PopoverTrigger } from './popover';
5+
import { Icon } from '@/shared/icons';
6+
import { cn } from '@/shared/lib';
7+
import { useEffect, useState } from 'react';
8+
9+
interface DatePickerProps {
10+
value?: Date;
11+
onChange?: (date: Date) => void;
12+
defaultValue?: Date;
13+
className?: string;
14+
}
15+
const toFirstOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1);
16+
export function DatePicker({
17+
value,
18+
onChange,
19+
defaultValue,
20+
className,
21+
...calendarProps
22+
}: DatePickerProps) {
23+
const [open, setOpen] = useState(false);
24+
const [innerDate, setInnerDate] = useState<Date | undefined>(defaultValue);
25+
26+
const [displayMonth, setDisplayMonth] = useState<Date>(
27+
toFirstOfMonth(value ?? innerDate ?? new Date()),
28+
);
29+
30+
useEffect(() => {
31+
if (value !== undefined) {
32+
setInnerDate(value);
33+
setDisplayMonth(toFirstOfMonth(value));
34+
}
35+
}, [value]);
36+
37+
useEffect(() => {
38+
if (open) {
39+
const base = value ?? innerDate ?? new Date();
40+
setDisplayMonth(toFirstOfMonth(base));
41+
}
42+
}, [open, value, innerDate]);
43+
44+
const selected = value ?? innerDate;
45+
46+
const handleSelect = (d?: Date) => {
47+
if (!d) return;
48+
onChange ? onChange(d) : setInnerDate(d);
49+
setDisplayMonth(toFirstOfMonth(d));
50+
setOpen(false);
51+
};
52+
53+
const today = new Date();
54+
const startOfToday = new Date(
55+
today.getFullYear(),
56+
today.getMonth(),
57+
today.getDate(),
58+
);
59+
60+
return (
61+
<div className={cn('flex flex-col gap-3', className)}>
62+
<Popover open={open} onOpenChange={setOpen}>
63+
<PopoverTrigger asChild>
64+
<Button className='w-[16rem] h-[4rem] justify-between rounded-[500px] text-label-lg text-pink-300 bg-pink-50 border border-pink-100'>
65+
{selected ? selected.toLocaleDateString() : 'Select date'}
66+
<Icon name='CalendarBlank' color='pink-400' size={14} />
67+
</Button>
68+
</PopoverTrigger>
69+
70+
<PopoverContent className='w-[24rem] p-0' align='start'>
71+
<Calendar
72+
mode='single'
73+
className='w-[24rem] h-auto p-3 [--cell-size:2.8rem]'
74+
selected={selected}
75+
onSelect={handleSelect}
76+
month={displayMonth}
77+
onMonthChange={setDisplayMonth}
78+
captionLayout='dropdown'
79+
disabled={{ before: startOfToday }}
80+
fromDate={startOfToday}
81+
toYear={new Date(new Date().getFullYear() + 5, 0, 1).getFullYear()}
82+
{...calendarProps}
83+
/>
84+
</PopoverContent>
85+
</Popover>
86+
</div>
87+
);
88+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
'use client';
2+
import * as PopoverPrimitive from '@radix-ui/react-popover';
3+
import { cn } from '@/shared/lib';
4+
import { forwardRef } from 'react';
5+
6+
export const PopoverContent = forwardRef<
7+
React.ElementRef<typeof PopoverPrimitive.Content>,
8+
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
9+
>(function PopoverContent(
10+
{ className, align = 'center', sideOffset = 4, ...props },
11+
ref,
12+
) {
13+
return (
14+
<PopoverPrimitive.Portal>
15+
<PopoverPrimitive.Content
16+
ref={ref}
17+
data-slot='popover-content'
18+
align={align}
19+
sideOffset={sideOffset}
20+
className={cn(
21+
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0',
22+
'data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
23+
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
24+
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
25+
'z-50 w-72 origin-(--radix-popover-content-transform-origin)',
26+
className,
27+
)}
28+
{...props}
29+
/>
30+
</PopoverPrimitive.Portal>
31+
);
32+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import * as React from 'react';
2+
import { Slot } from '@radix-ui/react-slot';
3+
import { cva, type VariantProps } from 'class-variance-authority';
4+
5+
import { cn } from '@/shared/lib';
6+
7+
const buttonVariants = cva(
8+
'inline-flex items-center justify-center gap-2 whitespace-nowrap transition-all ',
9+
{
10+
variants: {
11+
variant: {
12+
default: 'px-[1.5rem] ',
13+
ghost: ' text-label-lg',
14+
},
15+
},
16+
defaultVariants: {
17+
variant: 'default',
18+
},
19+
},
20+
);
21+
22+
function Button({
23+
className,
24+
variant,
25+
asChild = false,
26+
...props
27+
}: React.ComponentProps<'button'> &
28+
VariantProps<typeof buttonVariants> & {
29+
asChild?: boolean;
30+
}) {
31+
const Comp = asChild ? Slot : 'button';
32+
33+
return (
34+
<Comp
35+
data-slot='button'
36+
className={cn(buttonVariants({ variant }), className)}
37+
{...props}
38+
/>
39+
);
40+
}
41+
42+
export { Button, buttonVariants };

0 commit comments

Comments
 (0)