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
5 changes: 5 additions & 0 deletions public/icons/time-picker/reset-active.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions public/icons/time-picker/reset-disabled.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -253,3 +253,15 @@ describe("web time editing", () => {
expect(mocks.error).toHaveBeenCalledWith("synthetic failure");
});
});


it("opening and focusing unset desktop fields never enables saving or sends a PATCH", async () => {
props = { ...props, startTime: "", endTime: null };
await render();
await act(async () => { input(0).focus(); input(1).focus(); });
expect(fields.map((_, i) => selected(i))).toEqual(["", "", "", ""]);
const apply = [...document.querySelectorAll("button")].find(b => b.textContent === "적용")!;
expect(apply.disabled).toBe(true);
await click("적용");
expect(mocks.mutateAsync).not.toHaveBeenCalled();
});
35 changes: 35 additions & 0 deletions src/app/(main)/plan/_components/itinerary/TimeWheelPicker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";
import { TimeWheelPicker } from "./TimeWheelPicker";

it("alignment, focus and programmatic scrolling never turn the preview into a selection", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
const host = document.createElement("div"); document.body.append(host);
const root = createRoot(host);
const change = vi.fn();
try {
await act(async () => root.render(<TimeWheelPicker value={{ period: null, hour: null, minute: null }} onChange={change} active onSettlingChange={() => {}} />));
const wheels = host.querySelectorAll<HTMLElement>('[role="listbox"]');
expect(wheels).toHaveLength(3);
await act(async () => {
for (const wheel of wheels) {
wheel.focus(); wheel.scrollTop = 40;
wheel.dispatchEvent(new Event("scroll", { bubbles: true }));
wheel.dispatchEvent(new Event("scrollend", { bubbles: true }));
}
});
expect(change).not.toHaveBeenCalled();
await act(async () => {
wheels[0].dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
wheels[0].dispatchEvent(new Event("scrollend", { bubbles: true }));
});
expect(change).not.toHaveBeenCalled();
const am = host.querySelector<HTMLButtonElement>('[data-value="AM"]')!;
await act(async () => am.click());
expect(change).toHaveBeenCalledExactlyOnceWith({ period: "AM", hour: null, minute: null });
} finally {
await act(async () => root.unmount()); host.remove(); vi.unstubAllGlobals();
}
});
219 changes: 128 additions & 91 deletions src/app/(main)/plan/_components/itinerary/TimeWheelPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,107 +1,144 @@
"use client";

import {
WheelPicker,
WheelPickerWrapper,
type WheelPickerOption,
} from "@ncdai/react-wheel-picker";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import type { TimeParts } from "@/lib/plan/timePickerDraft";
import { cn } from "@/lib/utils";

type Period = "AM" | "PM";
type Option = { value: string; label: string };
const PERIOD_OPTIONS: Option[] = [{ value: "AM", label: "오전" }, { value: "PM", label: "오후" }];
const HOUR_OPTIONS: Option[] = Array.from({ length: 12 }, (_, i) => ({ value: String((i + 1) % 12), label: String(i + 1) }));
const MINUTE_OPTIONS: Option[] = Array.from({ length: 60 }, (_, i) => ({ value: String(i), label: String(i).padStart(2, "0") }));
const ROW_HEIGHT = 40;

/** Figma Time Picker Base — 행 44px, 5행(≈220px)이 보이도록 링 개수 16 */
const OPTION_HEIGHT = 44;
const VISIBLE_COUNT = 16;
/** Figma 5065:3515: five flat 40px rows; the selected band is independently 44px. */
function TimeWheelColumn({ label, options, value, preview, active, cyclic, onSelect, onScrollingChange }: Readonly<{
label: string;
options: Option[];
value: string | null;
preview: string;
active: boolean;
cyclic: boolean;
onSelect: (value: string) => void;
onScrollingChange: (label: string, scrolling: boolean) => void;
}>) {
const id = useId();
const scroller = useRef<HTMLDivElement>(null);
const userScroll = useRef(false);
const gestureStartTop = useRef(0);
const alignedIndex = options.findIndex(option => option.value === (value ?? preview));
const targetIndex = (cyclic ? options.length : 0) + alignedIndex;
const [centerIndex, setCenterIndex] = useState(targetIndex);
const rows = cyclic ? [...options, ...options, ...options] : options;

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") }),
);
useLayoutEffect(() => {
// Alignment on opening, field switching, or reset must never count as input.
userScroll.current = false;
onScrollingChange(label, false);
if (scroller.current) scroller.current.scrollTop = targetIndex * ROW_HEIGHT;
}, [targetIndex, label, onScrollingChange]);

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 };
}
useEffect(() => () => onScrollingChange(label, false), [label, onScrollingChange]);

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 beginScroll() {
if (!active || userScroll.current) return;
gestureStartTop.current = scroller.current?.scrollTop ?? 0;
userScroll.current = true;
}

/** 12시간제 휠 — `value`/`onChange`는 24시간 HH:mm */
export function TimeWheelPicker({
value,
onChange,
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 shared = {
classNames,
optionItemHeight: OPTION_HEIGHT,
visibleCount: VISIBLE_COUNT,
};
function select(index: number) {
const row = rows[index];
if (!active || !row) return;
userScroll.current = false;
onScrollingChange(label, false);
const centered = cyclic ? options.length + index % options.length : index;
setCenterIndex(centered);
if (scroller.current) scroller.current.scrollTop = centered * ROW_HEIGHT;
onSelect(row.value);
}

return (
<div
className="relative w-full p-2"
onPointerDownCapture={onInteract}
onWheelCapture={onInteract}
onKeyDownCapture={(e) => {
if (e.key !== "Tab" && e.key !== "Escape") onInteract();
ref={scroller}
role="listbox"
aria-label={label}
aria-disabled={!active}
aria-activedescendant={`${id}-${centerIndex}`}
tabIndex={active ? 0 : -1}
className="relative h-[200px] snap-y snap-mandatory overflow-y-auto overscroll-contain py-[80px] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden focus-visible:outline-2 focus-visible:outline-primary"
onPointerDown={beginScroll}
onTouchStart={beginScroll}
onWheel={beginScroll}
onScroll={event => {
setCenterIndex(Math.max(0, Math.min(rows.length - 1, Math.round(event.currentTarget.scrollTop / ROW_HEIGHT))));
if (active && userScroll.current) onScrollingChange(label, true);
}}
onScrollEnd={event => {
if (!active || !userScroll.current) return;
if (Math.abs(event.currentTarget.scrollTop - gestureStartTop.current) < ROW_HEIGHT / 2) {
userScroll.current = false;
onScrollingChange(label, false);
return;
}
const index = Math.max(0, Math.min(rows.length - 1, Math.round(event.currentTarget.scrollTop / ROW_HEIGHT)));
select(index);
}}

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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unknown property 'onScrollEnd' found

See more on https://sonarcloud.io/project/issues?id=uttae_frontend-server&issues=AaGBzwFc5ZYDiRoZyOT7&open=AaGBzwFc5ZYDiRoZyOT7&pullRequest=249
onKeyDown={event => {
const offset = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0;

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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=uttae_frontend-server&issues=AaGBzwFc5ZYDiRoZyOT8&open=AaGBzwFc5ZYDiRoZyOT8&pullRequest=249
if (!active || (!offset && event.key !== "Enter" && event.key !== " ")) return;
event.preventDefault();
select(Math.max(0, Math.min(rows.length - 1, centerIndex + offset)));
}}
>
<div
aria-hidden
className="absolute inset-x-0 top-1/2 h-11 -translate-y-1/2 rounded-md bg-fill"
/>
<WheelPickerWrapper className="justify-center gap-2">
<div role="group" aria-label="오전/오후" className="w-[88px]">
<WheelPicker
{...shared}
options={PERIOD_OPTIONS}
value={period}
onValueChange={(next) => onChange(joinHm(next, hour, minute))}
/>
</div>
<div role="group" aria-label="시" className="w-16">
<WheelPicker
{...shared}
infinite
options={HOUR_OPTIONS}
value={hour}
onValueChange={(next) => onChange(joinHm(period, next, minute))}
/>
</div>
<div role="group" aria-label="분" className="w-16">
<WheelPicker
{...shared}
infinite
options={MINUTE_OPTIONS}
value={minute}
onValueChange={(next) => onChange(joinHm(period, hour, next))}
/>
</div>
</WheelPickerWrapper>
{rows.map((option, index) => (
<button
key={`${index}-${option.value}`}
id={`${id}-${index}`}
type="button"
role="option"
data-value={option.value}
aria-selected={value === option.value && index === centerIndex}
tabIndex={-1}
disabled={!active}
onClick={() => select(index)}
className={cn(
"flex h-[40px] w-full shrink-0 snap-center items-center justify-center select-none",
index === centerIndex ? "text-body-l-emphasis" : "text-body-l-regular",
!active ? "text-text-disabled" : index === centerIndex ? "text-text" : "text-text-subtle",

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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=uttae_frontend-server&issues=AaGBzwFc5ZYDiRoZyOT9&open=AaGBzwFc5ZYDiRoZyOT9&pullRequest=249
)}
>{option.label}</button>
))}
</div>
);
}

/** Preview alignment is visual only. Each missing column requires a user selection. */
export function TimeWheelPicker({ value, onChange, active, onSettlingChange }: Readonly<{
value: TimeParts;
onChange: (parts: TimeParts) => void;
active: boolean;
onSettlingChange: (settling: boolean) => void;
}>) {
const scrollingColumns = useRef(new Set<string>());
const reportScrolling = useCallback((label: string, scrolling: boolean) => {
if (scrolling) scrollingColumns.current.add(label);
else scrollingColumns.current.delete(label);
onSettlingChange(scrollingColumns.current.size > 0);
}, [onSettlingChange]);
return (
<div className="relative flex min-w-[300px] w-full justify-center gap-2 px-4 py-2" inert={!active}>
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-1/2 h-11 -translate-y-1/2 rounded-md bg-fill" />
<div className="w-[88px] shrink-0">
<TimeWheelColumn label="오전/오후" options={PERIOD_OPTIONS} value={value.period} preview="AM" active={active} cyclic={false} onScrollingChange={reportScrolling}
onSelect={period => { if (period === "AM" || period === "PM") onChange({ ...value, period }); }} />
</div>
<div className="w-16 shrink-0">
<TimeWheelColumn label="시" options={HOUR_OPTIONS} value={value.hour === null ? null : String(value.hour)} preview="10" active={active} cyclic onScrollingChange={reportScrolling}
onSelect={hour => onChange({ ...value, hour: Number(hour) })} />
</div>
<div className="w-16 shrink-0">
<TimeWheelColumn label="분" options={MINUTE_OPTIONS} value={value.minute === null ? null : String(value.minute)} preview="0" active={active} cyclic onScrollingChange={reportScrolling}
onSelect={minute => onChange({ ...value, minute: Number(minute) })} />
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { beforeEach, afterEach, expect, it, vi } from "vitest";
vi.mock("@/hooks/useRooms", () => ({ useUpdateScheduleItem: () => ({ mutateAsync: vi.fn(), isPending: false }) }));
import { MobileTimeSheet } from "./MobileTimeSheet";
let root: Root;
let host: HTMLDivElement;
beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); host=document.createElement("div"); document.body.append(host); root=createRoot(host); });
afterEach(async () => { await act(async () => root.unmount()); host.remove(); vi.unstubAllGlobals(); });
async function open(startTime: string | null = null) {
await act(async () => root.render(<MobileTimeSheet roomId="room" scheduleId={1} itemId={1} placeName="Place" startTime={startTime} endTime={null} onClose={() => {}} />));
}
it("has no unset choices in the wheel while the time fields stay unset", async () => {
await open();
for (const name of ["오전/오후","시","분"]) {
const group=document.querySelector(`[aria-label="${name}"]`)!;
expect(group).not.toBeNull(); expect(group.textContent).not.toContain("--");
}
expect(document.querySelector('[aria-label="시작 시간 미설정"]')).not.toBeNull();
});
it("starts in Enabled state with a visible disabled reset control", async () => {
await open();
const reset=[...document.querySelectorAll("button")].find(b => b.textContent?.trim()==="초기화");
expect(reset).toBeDefined(); expect(reset?.disabled).toBe(true);
expect(document.querySelector('[aria-label="시작 시간 미설정"]')?.getAttribute("aria-expanded")).toBe("false");
});
it.each([["00:05","오전 12:05"],["12:05","오후 12:05"]])("labels %s as %s without rewriting storage", async (start,display) => {
await open(start);
expect(document.querySelector('[aria-label^="시작 시간"]')?.textContent).toContain(display);
});
Loading