diff --git a/public/icons/time-picker/reset-active.svg b/public/icons/time-picker/reset-active.svg new file mode 100644 index 00000000..dd5b85c2 --- /dev/null +++ b/public/icons/time-picker/reset-active.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons/time-picker/reset-disabled.svg b/public/icons/time-picker/reset-disabled.svg new file mode 100644 index 00000000..8578f015 --- /dev/null +++ b/public/icons/time-picker/reset-disabled.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/(main)/plan/_components/itinerary/PlanItemTimeForm.test.tsx b/src/app/(main)/plan/_components/itinerary/PlanItemTimeForm.test.tsx index 6cf42a12..c1787bcb 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanItemTimeForm.test.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanItemTimeForm.test.tsx @@ -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(); +}); diff --git a/src/app/(main)/plan/_components/itinerary/TimeWheelPicker.test.tsx b/src/app/(main)/plan/_components/itinerary/TimeWheelPicker.test.tsx new file mode 100644 index 00000000..9c345c56 --- /dev/null +++ b/src/app/(main)/plan/_components/itinerary/TimeWheelPicker.test.tsx @@ -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( {}} />)); + const wheels = host.querySelectorAll('[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('[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(); + } +}); diff --git a/src/app/(main)/plan/_components/itinerary/TimeWheelPicker.tsx b/src/app/(main)/plan/_components/itinerary/TimeWheelPicker.tsx index 56ebcb59..950c012d 100644 --- a/src/app/(main)/plan/_components/itinerary/TimeWheelPicker.tsx +++ b/src/app/(main)/plan/_components/itinerary/TimeWheelPicker.tsx @@ -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(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[] = [ - { value: "AM", label: "오전" }, - { value: "PM", label: "오후" }, -]; -const HOUR_OPTIONS: WheelPickerOption[] = Array.from( - { length: 12 }, - (_, i) => ({ value: i, label: String(i) }), -); -const MINUTE_OPTIONS: WheelPickerOption[] = 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 (
{ - 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); + }} + onKeyDown={event => { + const offset = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0; + if (!active || (!offset && event.key !== "Enter" && event.key !== " ")) return; + event.preventDefault(); + select(Math.max(0, Math.min(rows.length - 1, centerIndex + offset))); }} > -
- -
- onChange(joinHm(next, hour, minute))} - /> -
-
- onChange(joinHm(period, next, minute))} - /> -
-
- onChange(joinHm(period, hour, next))} - /> -
-
+ {rows.map((option, index) => ( + + ))} +
+ ); +} + +/** 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()); + 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 ( +
+
+
+ { if (period === "AM" || period === "PM") onChange({ ...value, period }); }} /> +
+
+ onChange({ ...value, hour: Number(hour) })} /> +
+
+ onChange({ ...value, minute: Number(minute) })} /> +
); } diff --git a/src/app/(main)/plan/_components/mobile/MobileTimeSheet.design.test.tsx b/src/app/(main)/plan/_components/mobile/MobileTimeSheet.design.test.tsx new file mode 100644 index 00000000..f9b5bbf2 --- /dev/null +++ b/src/app/(main)/plan/_components/mobile/MobileTimeSheet.design.test.tsx @@ -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( {}} />)); +} +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); +}); diff --git a/src/app/(main)/plan/_components/mobile/MobileTimeSheet.test.tsx b/src/app/(main)/plan/_components/mobile/MobileTimeSheet.test.tsx new file mode 100644 index 00000000..e00ad041 --- /dev/null +++ b/src/app/(main)/plan/_components/mobile/MobileTimeSheet.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment jsdom +import { act, type ComponentProps } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +const save = vi.hoisted(() => vi.fn()); +vi.mock("@/hooks/useRooms", () => ({ useUpdateScheduleItem: () => ({ mutateAsync: save, isPending: false }) })); +import { MobileTimeSheet } from "./MobileTimeSheet"; +let root: Root; +let container: HTMLDivElement; +let props: ComponentProps; +const button = (label: string) => [...document.querySelectorAll("button")].find(b => b.textContent === label || b.getAttribute("aria-label")?.startsWith(label))!; +async function click(label: string) { await act(async () => button(label).click()); } +async function render() { await act(async () => root.render()); } +async function choose(group: string, value: string) { + const el = document.querySelector(`[role="listbox"][aria-label="${group}"] [data-value="${value}"]`); + expect(el).not.toBeNull(); expect(el!.disabled).toBe(false); + await act(async () => el!.click()); +} +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + save.mockReset().mockResolvedValue({}); + container = document.createElement("div"); document.body.append(container); root = createRoot(container); + props = { roomId: "room", scheduleId: 10, itemId: 1, placeName: "Place", startTime: null, endTime: null, onClose: vi.fn() }; +}); +afterEach(async () => { await act(async () => root.unmount()); container.remove(); vi.unstubAllGlobals(); }); +it("opening an unset time leaves both unset and cannot save", async () => { + await render(); + expect(button("시작 시간").getAttribute("aria-label")).toContain("미설정"); + expect(button("종료 시간").getAttribute("aria-label")).toContain("미설정"); + expect(button("확인").disabled).toBe(true); + await click("확인"); expect(save).not.toHaveBeenCalled(); +}); +it("opening an unset end does not invent a duration", async () => { + props = { ...props, startTime: "14:30" }; await render(); await click("종료 시간"); + expect(button("종료 시간").getAttribute("aria-label")).toContain("미설정"); + expect(button("확인").disabled).toBe(true); expect(save).not.toHaveBeenCalled(); +}); +it("requires explicit period, hour and minute before saving start only", async () => { + await render(); await click("시작 시간"); + await choose("시", "0"); expect(button("확인").disabled).toBe(true); + await choose("분", "0"); expect(button("확인").disabled).toBe(true); + await choose("오전/오후", "AM"); await click("확인"); + expect(save).toHaveBeenCalledExactlyOnceWith({ roomId: "room", scheduleId: 10, itemId: 1, body: { startTime: "00:00" } }); +}); +it("explicitly sets an overnight end without rewriting start", async () => { + props = { ...props, startTime: "23:15" }; await render(); await click("종료 시간"); + await choose("오전/오후", "AM"); await choose("시", "1"); await choose("분", "5"); await click("확인"); + expect(save.mock.calls[0][0].body).toEqual({ endTime: "01:05" }); +}); +it("changes an existing time without altering its unset end", async () => { + props = { ...props, startTime: "09:07" }; await render(); await choose("분", "8"); await click("확인"); + expect(save.mock.calls[0][0].body).toEqual({ startTime: "09:08" }); +}); +it.each(["취소", "방문 시간 닫기"])("%s never saves edits or deletions", async label => { + props = { ...props, startTime: "09:07", endTime: "10:13" }; await render(); await click("초기화"); await click(label); + expect(save).not.toHaveBeenCalled(); expect(props.onClose).toHaveBeenCalledOnce(); +}); +it("deletes both times only on confirmation", async () => { + props = { ...props, startTime: "09:07", endTime: "10:13" }; await render(); await click("초기화"); + expect(save).not.toHaveBeenCalled(); await click("확인"); + expect(save.mock.calls[0][0].body).toEqual({ startTime: null, endTime: null }); +}); +it("cannot save a start change while the end has only been partially chosen", async () => { + props = { ...props, startTime: "09:07" }; await render(); + await choose("분", "8"); await click("종료 시간"); await choose("오전/오후", "AM"); + expect(button("확인").disabled).toBe(true); + await click("확인"); expect(save).not.toHaveBeenCalled(); +}); +it("keeps partial end choices when switching fields and never fills the missing minute", async () => { + props = { ...props, startTime: "09:07" }; await render(); + await click("종료 시간"); await choose("오전/오후", "AM"); await choose("시", "1"); + await click("시작 시간"); await click("종료 시간"); + expect(button("확인").disabled).toBe(true); expect(save).not.toHaveBeenCalled(); + await choose("분", "5"); await click("확인"); + expect(save.mock.calls[0][0].body).toEqual({ endTime: "01:05" }); +}); +it.each([["AM", "00:07"], ["PM", "12:07"]])("explicit %s twelve saves %s", async (period, expected) => { + await render(); await click("시작 시간"); + await choose("오전/오후", period); await choose("시", "0"); await choose("분", "7"); + await click("확인"); expect(save.mock.calls[0][0].body).toEqual({ startTime: expected }); +}); +it("a save failure keeps the chosen value and sheet open", async () => { + props = { ...props, startTime: "09:07" }; await render(); await choose("분", "8"); + save.mockRejectedValueOnce(new Error("save failed")); await click("확인"); + expect(props.onClose).not.toHaveBeenCalled(); + expect(button("시작 시간").textContent).toContain("9:08"); +}); +it("blocks confirmation and field switching until a user scroll settles", async () => { + props = { ...props, startTime: "09:07", endTime: "10:13" }; await render(); + await choose("분", "14"); + const hours = document.querySelector('[role="listbox"][aria-label="시"]')!; + await act(async () => { + hours.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); + hours.scrollTop += 40; + hours.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + expect(button("확인").disabled).toBe(true); + expect(button("시작 시간").disabled).toBe(true); + expect(save).not.toHaveBeenCalled(); + await act(async () => hours.dispatchEvent(new Event("scrollend", { bubbles: true }))); + await click("확인"); + expect(save.mock.calls[0][0].body).toEqual({ endTime: "11:14" }); +}); +it("releases pending scroll when an untouched server field is refreshed", async () => { + props = { ...props, startTime: "09:07", endTime: "10:13" }; await render(); + const hours = document.querySelector('[role="listbox"][aria-label="시"]')!; + await act(async () => { + hours.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); + hours.scrollTop += 40; hours.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + expect(button("시작 시간").disabled).toBe(true); + props = { ...props, endTime: "11:13" }; await render(); + expect(button("시작 시간").disabled).toBe(false); + expect(button("확인").disabled).toBe(true); + expect(save).not.toHaveBeenCalled(); +}); diff --git a/src/app/(main)/plan/_components/mobile/MobileTimeSheet.tsx b/src/app/(main)/plan/_components/mobile/MobileTimeSheet.tsx index d9653e55..fff0f90b 100644 --- a/src/app/(main)/plan/_components/mobile/MobileTimeSheet.tsx +++ b/src/app/(main)/plan/_components/mobile/MobileTimeSheet.tsx @@ -1,6 +1,8 @@ "use client"; import { useState } from "react"; +import Image from "next/image"; +import { formatTimePickerValue, timePartsFromHm, timePartsToHm, type TimeParts } from "@/lib/plan/timePickerDraft"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -16,10 +18,10 @@ import { } from "@/components/ui/drawer"; import { useUpdateScheduleItem } from "@/hooks/useRooms"; import { - addMinutesToHm, computeDurationMinutesFromRange, normalizeStartTimeToHm, validateScheduleTimeDraft, + type ScheduleTimeDraftValidation, } from "@/lib/plan/scheduleTime"; import { cn } from "@/lib/utils"; @@ -39,15 +41,6 @@ type MobileTimeSheetProps = Readonly<{ onClose: () => void; }>; -const DEFAULT_HM = "10:00"; - -/** "13:05" → "오후 1:05" — 휠과 같이 시를 0~11로 표기 */ -function formatHm12h(hm: string): string { - if (!hm) return ""; - const [h, m] = hm.split(":").map(Number); - return `${h < 12 ? "오전" : "오후"} ${h % 12}:${String(m).padStart(2, "0")}`; -} - /** 방문 시간 시트 — 시작·종료 칸을 누르면 휠 피커로 고른다 */ export function MobileTimeSheet({ roomId, @@ -61,32 +54,36 @@ export function MobileTimeSheet({ }: MobileTimeSheetProps) { const serverStart = normalizeStartTimeToHm(startTime ?? ""); const serverEnd = normalizeStartTimeToHm(endTime ?? ""); - const [start, setStart] = useState(serverStart || DEFAULT_HM); - const [end, setEnd] = useState(serverEnd); - const [activeField, setActiveField] = useState("start"); + const [draftStart, setStart] = useState(); + const [draftEnd, setEnd] = useState(); + const startParts = draftStart ?? timePartsFromHm(serverStart); + const endParts = draftEnd ?? timePartsFromHm(serverEnd); + const start = timePartsToHm(startParts); + const end = timePartsToHm(endParts); + const [activeField, setActiveField] = useState(serverEnd ? "end" : serverStart ? "start" : null); + const [isWheelSettling, setWheelSettling] = useState(false); const { mutateAsync, isPending } = useUpdateScheduleItem(); const dirty = start !== serverStart || end !== serverEnd; - const duration = computeDurationMinutesFromRange(start, end); + const duration = computeDurationMinutesFromRange(start ?? "", end ?? ""); const subtitle = [ dateLabel, - duration != null && end < start ? "다음 날 종료(+1일)" : null, + duration != null && end !== null && start !== null && end < start ? "다음 날 종료(+1일)" : null, duration != null ? `${Math.floor(duration / 60)}시간 ${duration % 60}분` : null, ] .filter(Boolean) .join(" · "); - const validation = validateScheduleTimeDraft(start, end); + const validation: ScheduleTimeDraftValidation = start === null || end === null + ? { valid: false, message: "오전/오후, 시, 분을 모두 선택해 주세요." } + : validateScheduleTimeDraft(start, end); function openField(field: Field) { if (field === "end" && !start) return; setActiveField((current) => (current === field ? null : field)); - // 비어 있는 칸을 처음 열면 휠 기본값으로 채운다 - if (field === "start" && !start) setStart(DEFAULT_HM); - if (field === "end" && !end) setEnd(addMinutesToHm(start, 60)); } async function handleSave() { - if (isPending || !dirty) return; + if (isPending || isWheelSettling || !dirty) return; if (!validation.valid) { toast.error(validation.message); return; @@ -108,7 +105,7 @@ export function MobileTimeSheet({ } } - const fields: { key: Field; label: string; value: string }[] = [ + const fields: { key: Field; label: string; value: string | null }[] = [ { key: "start", label: "시작 시간", value: start }, { key: "end", label: "종료 시간", value: end }, ]; @@ -120,21 +117,21 @@ export function MobileTimeSheet({ {placeName} {subtitle ? {subtitle} : null} - -
+ +
{fields.map(({ key, label, value }) => { const active = activeField === key; - const display = formatHm12h(value); + const display = formatTimePickerValue(value); return (
- {activeField ? ( - // 휠은 세로로 끌어 고른다. 시트가 같이 끌려 닫히지 않게 Base UI 스와이프에서 뺀다(옛 시트도 제목 줄에서만 끌렸다) -
- {}} - /> -
- ) : null} - - {start || end ? ( -
- -
- ) : null} +
+ +
+
+ +
}>취소