fix(space-ui): /space 포커스 앵커 잘림과 스크롤 문제 수정
This commit is contained in:
@@ -1,51 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import type { FormEvent } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
|
||||
import type { RoomTheme } from '@/entities/room';
|
||||
import type { GoalChip, SoundPreset } from '@/entities/session';
|
||||
import type { GoalChip, SoundPreset, TimerPreset } from '@/entities/session';
|
||||
import { SpaceSelectCarousel } from '@/features/space-select';
|
||||
import { SessionGoalField } from '@/features/session-goal';
|
||||
import { Button } from '@/shared/ui';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import { SpaceSideSheet } from '@/widgets/space-sheet-shell';
|
||||
|
||||
type RitualPopover = 'space' | 'timer' | 'sound';
|
||||
|
||||
interface SpaceSetupDrawerWidgetProps {
|
||||
open: boolean;
|
||||
dismissible?: boolean;
|
||||
rooms: RoomTheme[];
|
||||
selectedRoomId: string;
|
||||
selectedTimerLabel: string;
|
||||
selectedSoundPresetId: string;
|
||||
goalInput: string;
|
||||
selectedGoalId: string | null;
|
||||
selectedSoundPresetId: string;
|
||||
goalChips: GoalChip[];
|
||||
soundPresets: SoundPreset[];
|
||||
timerPresets: TimerPreset[];
|
||||
canStart: boolean;
|
||||
onClose: () => void;
|
||||
onRoomSelect: (roomId: string) => void;
|
||||
onTimerSelect: (timerLabel: string) => void;
|
||||
onSoundSelect: (soundPresetId: string) => void;
|
||||
onGoalChange: (value: string) => void;
|
||||
onGoalChipSelect: (chip: GoalChip) => void;
|
||||
onSoundSelect: (soundPresetId: string) => void;
|
||||
onStart: () => void;
|
||||
}
|
||||
|
||||
interface SummaryChipProps {
|
||||
label: string;
|
||||
value: string;
|
||||
open: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const SummaryChip = ({ label, value, open, onClick }: SummaryChipProps) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-[11px] transition-colors',
|
||||
open
|
||||
? 'border-sky-200/42 bg-sky-200/14 text-white/92'
|
||||
: 'border-white/14 bg-white/[0.04] text-white/80 hover:bg-white/[0.09]',
|
||||
)}
|
||||
>
|
||||
<span className="text-white/62">{label}</span>
|
||||
<span className="max-w-[124px] truncate text-white/92">{value}</span>
|
||||
<span aria-hidden className="text-white/56">▾</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const SpaceSetupDrawerWidget = ({
|
||||
open,
|
||||
dismissible = true,
|
||||
rooms,
|
||||
selectedRoomId,
|
||||
selectedTimerLabel,
|
||||
selectedSoundPresetId,
|
||||
goalInput,
|
||||
selectedGoalId,
|
||||
selectedSoundPresetId,
|
||||
goalChips,
|
||||
soundPresets,
|
||||
timerPresets,
|
||||
canStart,
|
||||
onClose,
|
||||
onRoomSelect,
|
||||
onTimerSelect,
|
||||
onSoundSelect,
|
||||
onGoalChange,
|
||||
onGoalChipSelect,
|
||||
onSoundSelect,
|
||||
onStart,
|
||||
}: SpaceSetupDrawerWidgetProps) => {
|
||||
const [openPopover, setOpenPopover] = useState<RitualPopover | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const selectedRoom = useMemo(() => {
|
||||
return rooms.find((room) => room.id === selectedRoomId) ?? rooms[0];
|
||||
}, [rooms, selectedRoomId]);
|
||||
|
||||
const selectedSoundLabel = useMemo(() => {
|
||||
return (
|
||||
soundPresets.find((preset) => preset.id === selectedSoundPresetId)?.label ??
|
||||
soundPresets[0]?.label ??
|
||||
'기본'
|
||||
);
|
||||
}, [selectedSoundPresetId, soundPresets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openPopover) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpenPopover(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
|
||||
if (!panelRef.current?.contains(target)) {
|
||||
setOpenPopover(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.removeEventListener('mousedown', handlePointerDown);
|
||||
};
|
||||
}, [openPopover]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const togglePopover = (popover: RitualPopover) => {
|
||||
setOpenPopover((current) => (current === popover ? null : popover));
|
||||
};
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -57,44 +137,115 @@ export const SpaceSetupDrawerWidget = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<SpaceSideSheet
|
||||
open={open}
|
||||
title="오늘은 한 조각만."
|
||||
subtitle="공간을 고르고, 한 줄 목표를 적으면 시작돼요."
|
||||
onClose={onClose}
|
||||
dismissible={dismissible}
|
||||
widthClassName="w-[min(360px,94vw)]"
|
||||
footer={(
|
||||
<div className="space-y-1.5">
|
||||
{!canStart ? (
|
||||
<p className="text-[10px] text-white/56">목표를 적으면 시작할 수 있어요.</p>
|
||||
) : null}
|
||||
<Button
|
||||
type="submit"
|
||||
form="space-setup-ritual-form"
|
||||
size="full"
|
||||
disabled={!canStart}
|
||||
className={cn(
|
||||
'h-10 rounded-xl !bg-sky-300/84 !text-slate-900 shadow-[0_6px_14px_rgba(125,211,252,0.2)] hover:!bg-sky-300 disabled:!bg-white/10 disabled:!text-white/42',
|
||||
)}
|
||||
>
|
||||
시작하기
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<section
|
||||
className="fixed left-1/2 top-1/2 z-40 w-[min(428px,92vw)] -translate-x-1/2 -translate-y-1/2"
|
||||
aria-label="집중 시작 패널"
|
||||
>
|
||||
<form id="space-setup-ritual-form" className="space-y-4" onSubmit={handleSubmit}>
|
||||
<section className="space-y-2">
|
||||
<p className="text-[12px] font-medium text-white/84">공간</p>
|
||||
<SpaceSelectCarousel
|
||||
rooms={rooms}
|
||||
selectedRoomId={selectedRoomId}
|
||||
onSelect={onRoomSelect}
|
||||
/>
|
||||
</section>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="rounded-3xl border border-white/14 bg-[linear-gradient(160deg,rgba(15,23,42,0.68)_0%,rgba(8,13,27,0.56)_100%)] p-4 text-white shadow-[0_22px_52px_rgba(2,6,23,0.38)] backdrop-blur-2xl sm:p-5"
|
||||
>
|
||||
<header className="mb-3 space-y-1">
|
||||
<p className="text-[10px] uppercase tracking-[0.18em] text-white/48">Ritual</p>
|
||||
<h1 className="text-[1.45rem] font-semibold leading-tight text-white">이번 한 조각을 정하고 시작해요.</h1>
|
||||
<p className="text-xs text-white/60">목표만 적으면 바로 Focus 모드로 넘어가요.</p>
|
||||
</header>
|
||||
|
||||
<section className="space-y-2 border-t border-white/8 pt-3">
|
||||
<p className="text-[12px] font-medium text-white/84">이번 세션 한 줄(필수)</p>
|
||||
<div className="relative mb-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<SummaryChip
|
||||
label="공간"
|
||||
value={selectedRoom?.name ?? '기본 공간'}
|
||||
open={openPopover === 'space'}
|
||||
onClick={() => togglePopover('space')}
|
||||
/>
|
||||
<SummaryChip
|
||||
label="타이머"
|
||||
value={selectedTimerLabel}
|
||||
open={openPopover === 'timer'}
|
||||
onClick={() => togglePopover('timer')}
|
||||
/>
|
||||
<SummaryChip
|
||||
label="사운드"
|
||||
value={selectedSoundLabel}
|
||||
open={openPopover === 'sound'}
|
||||
onClick={() => togglePopover('sound')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{openPopover === 'space' ? (
|
||||
<div className="absolute left-0 right-0 top-[calc(100%+0.5rem)] z-20 rounded-2xl border border-white/14 bg-slate-950/80 p-2.5 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none">
|
||||
<SpaceSelectCarousel
|
||||
rooms={rooms.slice(0, 4)}
|
||||
selectedRoomId={selectedRoomId}
|
||||
onSelect={(roomId) => {
|
||||
onRoomSelect(roomId);
|
||||
setOpenPopover(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{openPopover === 'timer' ? (
|
||||
<div className="absolute left-0 top-[calc(100%+0.5rem)] z-20 rounded-2xl border border-white/14 bg-slate-950/80 p-3 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{timerPresets.slice(0, 3).map((preset) => {
|
||||
const selected = preset.label === selectedTimerLabel;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onTimerSelect(preset.label);
|
||||
setOpenPopover(null);
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-full border px-2.5 py-0.5 text-[10px] transition-colors',
|
||||
selected
|
||||
? 'border-sky-200/34 bg-sky-200/14 text-white/90'
|
||||
: 'border-white/12 bg-white/[0.03] text-white/66 hover:bg-white/8',
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{openPopover === 'sound' ? (
|
||||
<div className="absolute right-0 top-[calc(100%+0.5rem)] z-20 w-[min(300px,88vw)] rounded-2xl border border-white/14 bg-slate-950/80 p-3 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{soundPresets.slice(0, 6).map((preset) => {
|
||||
const selected = preset.id === selectedSoundPresetId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSoundSelect(preset.id);
|
||||
setOpenPopover(null);
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-full border px-2.5 py-0.5 text-[10px] transition-colors',
|
||||
selected
|
||||
? 'border-sky-200/34 bg-sky-200/14 text-white/90'
|
||||
: 'border-white/12 bg-white/[0.03] text-white/66 hover:bg-white/8',
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<form id="space-setup-ritual-form" className="space-y-3" onSubmit={handleSubmit}>
|
||||
<SessionGoalField
|
||||
autoFocus={open}
|
||||
goalInput={goalInput}
|
||||
@@ -103,34 +254,23 @@ export const SpaceSetupDrawerWidget = ({
|
||||
onGoalChange={onGoalChange}
|
||||
onGoalChipSelect={onGoalChipSelect}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2 border-t border-white/8 pt-3">
|
||||
<p className="text-[12px] font-medium text-white/84">분위기(선택)</p>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{soundPresets.slice(0, 6).map((preset) => {
|
||||
const selected = preset.id === selectedSoundPresetId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => onSoundSelect(preset.id)}
|
||||
className={cn(
|
||||
'rounded-full border px-2.5 py-0.5 text-[10px] transition-colors',
|
||||
selected
|
||||
? 'border-sky-200/30 bg-sky-200/12 text-white/90'
|
||||
: 'border-white/12 bg-white/[0.03] text-white/66 hover:bg-white/8',
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="space-y-1.5 pt-1">
|
||||
{!canStart ? <p className="text-[10px] text-white/56">목표를 적으면 시작할 수 있어요.</p> : null}
|
||||
<Button
|
||||
type="submit"
|
||||
form="space-setup-ritual-form"
|
||||
size="full"
|
||||
disabled={!canStart}
|
||||
className={cn(
|
||||
'h-10 rounded-xl !bg-sky-300/84 !text-slate-900 shadow-[0_8px_16px_rgba(125,211,252,0.24)] hover:!bg-sky-300 disabled:!bg-white/10 disabled:!text-white/42',
|
||||
)}
|
||||
>
|
||||
시작하기
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
</SpaceSideSheet>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user