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
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
},
"dependencies": {
"@amplitude/unified": "^1.1.28",
"@ncdai/react-wheel-picker": "^1.2.3",
"@next/third-parties": "^16.2.1",
"@stomp/stompjs": "^7.3.0",
"@tanstack/react-query": "^5.95.2",
Expand Down
285 changes: 88 additions & 197 deletions src/app/(main)/plan/_components/itinerary/TimeWheelPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,216 +1,107 @@
"use client";

import { useCallback, useEffect, useMemo, useRef } from "react";

import { cn } from "@/lib/utils";

const ITEM_HEIGHT = 40;
const VISIBLE_ROWS = 5;
const CENTER_INDEX = Math.floor(VISIBLE_ROWS / 2);
const CONTAINER_HEIGHT = VISIBLE_ROWS * ITEM_HEIGHT;
const CENTER_PADDING = CENTER_INDEX * ITEM_HEIGHT;
const SCROLL_SETTLE_MS = 110;

type WheelColumnProps = {
items: number[];
value: number;
onChange: (next: number) => void;
ariaLabel: string;
disabled?: boolean;
};
import {
WheelPicker,
WheelPickerWrapper,
type WheelPickerOption,
} from "@ncdai/react-wheel-picker";

type Period = "AM" | "PM";

/** Figma Time Picker Base — 행 44px, 5행(≈220px)이 보이도록 링 개수 16 */
const OPTION_HEIGHT = 44;
const VISIBLE_COUNT = 16;

const PERIOD_OPTIONS: WheelPickerOption<Period>[] = [
{ value: "AM", label: "오전" },
{ value: "PM", label: "오후" },
];
const HOUR_OPTIONS: WheelPickerOption<number>[] = Array.from(
{ length: 12 },
(_, i) => ({ value: i, label: String(i) }),
);
const MINUTE_OPTIONS: WheelPickerOption<number>[] = Array.from(
{ length: 60 },
(_, i) => ({ value: i, label: String(i).padStart(2, "0") }),
);

function splitHm(hm: string): { period: Period; hour: number; minute: number } {
const [h, m] = hm.split(":").map(Number);
return { period: h < 12 ? "AM" : "PM", hour: h % 12, minute: m };
}

function nearestIndex(items: number[], value: number): number {
let best = 0;
let bestDelta = Number.POSITIVE_INFINITY;
for (let i = 0; i < items.length; i += 1) {
const d = Math.abs(items[i]! - value);
if (d < bestDelta) {
best = i;
bestDelta = d;
}
}
return best;
function joinHm(period: Period, hour: number, minute: number): string {
const h = hour + (period === "PM" ? 12 : 0);
return `${String(h).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
}

function WheelColumn({
items,
/** 12시간제 휠 — `value`/`onChange`는 24시간 HH:mm */
export function TimeWheelPicker({
value,
onChange,
ariaLabel,
disabled = false,
}: WheelColumnProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const settleTimerRef = useRef<number | null>(null);
const suppressScrollRef = useRef(false);

const selectedIndex = nearestIndex(items, value);

const scrollToIndex = useCallback((index: number, smooth: boolean) => {
const el = scrollRef.current;
if (!el) return;
const target = index * ITEM_HEIGHT;
if (Math.abs(el.scrollTop - target) < 0.5) return;
suppressScrollRef.current = true;
el.scrollTo({ top: target, behavior: smooth ? "smooth" : "auto" });
window.setTimeout(() => {
suppressScrollRef.current = false;
}, smooth ? 260 : 40);
}, []);

useEffect(() => {
scrollToIndex(selectedIndex, false);
}, [selectedIndex, scrollToIndex]);

useEffect(() => {
return () => {
if (settleTimerRef.current !== null) {
window.clearTimeout(settleTimerRef.current);
}
};
}, []);

const commit = useCallback(
(index: number) => {
const clamped = Math.max(0, Math.min(items.length - 1, index));
onChange(items[clamped]!);
},
[items, onChange],
);

const handleScroll = () => {
if (suppressScrollRef.current) return;
const el = scrollRef.current;
if (!el) return;
if (settleTimerRef.current !== null) {
window.clearTimeout(settleTimerRef.current);
}
settleTimerRef.current = window.setTimeout(() => {
const idx = Math.round(el.scrollTop / ITEM_HEIGHT);
commit(idx);
}, SCROLL_SETTLE_MS);
active,
onInteract,
}: Readonly<{
value: string;
onChange: (hm: string) => void;
/** 편집 중인 필드가 있을 때만 중앙 값을 진하게 표시 */
active: boolean;
/** 휠을 건드리는 순간 — 편집 대상 필드가 없으면 부모가 지정 */
onInteract: () => void;
}>) {
const { period, hour, minute } = splitHm(value);
const classNames = {
optionItem: "text-heading-s text-text-disabled",
highlightWrapper: "bg-fill text-heading-s",
highlightItem: active ? "text-text" : "text-text-disabled",
};

const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
if (e.key === "ArrowUp") {
e.preventDefault();
commit(selectedIndex - 1);
} else if (e.key === "ArrowDown") {
e.preventDefault();
commit(selectedIndex + 1);
} else if (e.key === "PageUp") {
e.preventDefault();
commit(selectedIndex - 5);
} else if (e.key === "PageDown") {
e.preventDefault();
commit(selectedIndex + 5);
} else if (e.key === "Home") {
e.preventDefault();
commit(0);
} else if (e.key === "End") {
e.preventDefault();
commit(items.length - 1);
}
const shared = {
classNames,
optionItemHeight: OPTION_HEIGHT,
visibleCount: VISIBLE_COUNT,
};

return (
<div
ref={scrollRef}
role="listbox"
aria-label={ariaLabel}
aria-activedescendant={`${ariaLabel}-${items[selectedIndex]}`}
tabIndex={disabled ? -1 : 0}
onScroll={disabled ? undefined : handleScroll}
onKeyDown={handleKeyDown}
className={cn(
"w-14 snap-y snap-mandatory overflow-y-scroll outline-none",
"[&::-webkit-scrollbar]:hidden [scrollbar-width:none] [-ms-overflow-style:none]",
"rounded-lg focus-visible:ring-2 focus-visible:ring-primary/40",
disabled && "pointer-events-none opacity-60",
)}
style={{
height: CONTAINER_HEIGHT,
maskImage:
"linear-gradient(to bottom, transparent 0%, #000 22%, #000 78%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(to bottom, transparent 0%, #000 22%, #000 78%, transparent 100%)",
className="relative w-full p-2"
onPointerDownCapture={onInteract}
onWheelCapture={onInteract}
onKeyDownCapture={(e) => {
if (e.key !== "Tab" && e.key !== "Escape") onInteract();
}}
>
<div style={{ paddingTop: CENTER_PADDING, paddingBottom: CENTER_PADDING }}>
{items.map((v, i) => {
const isSelected = i === selectedIndex;
return (
<button
type="button"
key={v}
id={`${ariaLabel}-${v}`}
role="option"
aria-selected={isSelected}
tabIndex={-1}
onClick={() => commit(i)}
className={cn(
"flex w-full snap-center items-center justify-center text-title-m mobile:text-title-s font-semibold tabular-nums transition-colors",
isSelected ? "text-gray-900" : "text-dark-gray/50",
)}
style={{ height: ITEM_HEIGHT }}
>
{String(v).padStart(2, "0")}
</button>
);
})}
</div>
</div>
);
}

export type TimeWheelValue = { hour: number; minute: number };

type TimeWheelPickerProps = {
value: TimeWheelValue;
onChange: (next: TimeWheelValue) => void;
disabled?: boolean;
};

export function TimeWheelPicker({
value,
onChange,
disabled,
}: TimeWheelPickerProps) {
const hours = useMemo(() => Array.from({ length: 24 }, (_, i) => i), []);
const minutes = useMemo(() => Array.from({ length: 60 }, (_, i) => i), []);

return (
<div className="relative isolate flex items-center justify-center gap-2">
<span
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 z-0 rounded-xl bg-primary/[0.08] ring-1 ring-primary/25"
style={{
top: CENTER_INDEX * ITEM_HEIGHT,
height: ITEM_HEIGHT,
}}
className="absolute inset-x-0 top-1/2 h-11 -translate-y-1/2 rounded-md bg-fill"
/>
<div className="relative z-10 flex items-center gap-2">
<WheelColumn
items={hours}
value={value.hour}
onChange={(h) => onChange({ hour: h, minute: value.minute })}
ariaLabel="시"
disabled={disabled}
/>
<span
aria-hidden
className="pt-[2px] text-title-m mobile:text-title-s font-semibold text-gray-400"
>
:
</span>
<WheelColumn
items={minutes}
value={value.minute}
onChange={(m) => onChange({ hour: value.hour, minute: m })}
ariaLabel="분"
disabled={disabled}
/>
</div>
<WheelPickerWrapper className="justify-center gap-2">
<div role="group" aria-label="오전/오후" className="w-[88px]">

Check warning on line 78 in src/app/(main)/plan/_components/itinerary/TimeWheelPicker.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use <details>, <fieldset>, <optgroup>, or <address> instead of the "group" role to ensure accessibility across all devices.

See more on https://sonarcloud.io/project/issues?id=uttae_frontend-server&issues=AaEK5D0uWB1kMTkWjphd&open=AaEK5D0uWB1kMTkWjphd&pullRequest=217
<WheelPicker
{...shared}
options={PERIOD_OPTIONS}
value={period}
onValueChange={(next) => onChange(joinHm(next, hour, minute))}
/>
</div>
<div role="group" aria-label="시" className="w-16">

Check warning on line 86 in src/app/(main)/plan/_components/itinerary/TimeWheelPicker.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use <details>, <fieldset>, <optgroup>, or <address> instead of the "group" role to ensure accessibility across all devices.

See more on https://sonarcloud.io/project/issues?id=uttae_frontend-server&issues=AaEK5D0uWB1kMTkWjphe&open=AaEK5D0uWB1kMTkWjphe&pullRequest=217
<WheelPicker
{...shared}
infinite
options={HOUR_OPTIONS}
value={hour}
onValueChange={(next) => onChange(joinHm(period, next, minute))}
/>
</div>
<div role="group" aria-label="분" className="w-16">

Check warning on line 95 in src/app/(main)/plan/_components/itinerary/TimeWheelPicker.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use <details>, <fieldset>, <optgroup>, or <address> instead of the "group" role to ensure accessibility across all devices.

See more on https://sonarcloud.io/project/issues?id=uttae_frontend-server&issues=AaEK5D0uWB1kMTkWjphf&open=AaEK5D0uWB1kMTkWjphf&pullRequest=217
<WheelPicker
{...shared}
infinite
options={MINUTE_OPTIONS}
value={minute}
onValueChange={(next) => onChange(joinHm(period, hour, next))}
/>
</div>
</WheelPickerWrapper>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ type MobilePlanDaySectionProps = Readonly<{
dayLabel: string;
/** `09. 21 수` */
dateLabel: string;
/** `9월 21일` — 방문 시간 시트에 표시 */
monthDayLabel: string;
menuDisabled: boolean;
/** 순서 편집 모드 — 모든 일차가 함께 들어가고 나온다 */
editing: boolean;
Expand All @@ -43,6 +45,7 @@ export function MobilePlanDaySection({
scheduleId,
dayLabel,
dateLabel,
monthDayLabel,
menuDisabled,
editing,
onStartEditing,
Expand Down Expand Up @@ -104,6 +107,7 @@ export function MobilePlanDaySection({
<MobilePlanItinerary
roomId={roomId}
scheduleId={scheduleId}
monthDayLabel={monthDayLabel}
onRequestAddPlace={(places) => onRequestAddPlace(scheduleId, places)}
/>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ type SheetState =
type MobilePlanItineraryProps = Readonly<{
roomId: string;
scheduleId: number;
/** `9월 21일` — 방문 시간 시트에 표시 */
monthDayLabel?: string;
/** 전체 화면 검색으로 이 일차에 장소 추가 — 탭 제스처 안에서 호출해야 한다 */
onRequestAddPlace: (places: PlanPlace[]) => void;
}>;
Expand All @@ -62,6 +64,7 @@ type MobilePlanItineraryProps = Readonly<{
export function MobilePlanItinerary({
roomId,
scheduleId,
monthDayLabel,
onRequestAddPlace,
}: MobilePlanItineraryProps) {
const expenses = useExpenseContext();
Expand Down Expand Up @@ -256,6 +259,7 @@ export function MobilePlanItinerary({
scheduleId={scheduleId}
itemId={sheetItemId}
placeName={sheetPlace.title}
dateLabel={monthDayLabel}
startTime={sheetPlace.startTime}
endTime={sheetPlace.endTime}
onClose={() => setSheet(null)}
Expand Down
Loading
Loading