설정 화면
탭으로 설정을 나누고, 스위치와 선택 메뉴는 누르는 순간 저장합니다. 계정 삭제만 확인 대화 상자로 한 번 더 확인합니다.
설정
핵심 포인트
- 1
바로 저장되는 설정에는 저장 버튼을 두지 않기
스위치는 누른 순간 바뀌는 것이 자연스럽습니다. 대신 저장했다는 것을 알림으로 알려 줍니다.
- 2
되돌릴 수 없는 작업은 한 번 더 확인하기
삭제 전에 확인 대화 상자를 띄우고, 사용자가 "삭제"를 입력할 때까지 버튼을 누를 수 없게 합니다. 실수로 지우는 것을 막을 수 있습니다.
- 3
설정이 많으면 탭으로 나누기
계정, 알림, 화면 표시처럼 종류별로 나누면 한 화면이 짧아지고 찾기도 쉬워집니다.
사용한 도구
누르면 제2부의 비교 페이지로
코드 고치기
코드를 고치면 오른쪽 미리보기가 바로 바뀝니다.
import { useState } from "react" import { toast } from "sonner" import { Button } from "./components/ui/button" import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "./components/ui/dialog" import { Input } from "./components/ui/input" import { Label } from "./components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./components/ui/select" import { Switch } from "./components/ui/switch" import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs" import { Toaster } from "./components/ui/sonner" // 설정 한 줄: 왼쪽에 이름과 설명, 오른쪽에 스위치 등 function SettingRow({ title, description, children }: { title: string; description: string; children: React.ReactNode }) { return ( <div className="flex items-center justify-between gap-4 py-3"> <div> <p className="text-sm font-medium">{title}</p> <p className="text-xs text-muted-foreground">{description}</p> </div> {children} </div> ) } // ① 누르는 순간 저장된 것으로 하고 알림으로 알린다(실제로는 여기서 서버에 보낸다) function saved(message: string) { toast.success(message) } function AccountTab() { const [language, setLanguage] = useState("ja") return ( <div className="flex flex-col gap-6"> <div className="flex flex-col gap-2"> <Label>언어</Label> <Select value={language} onValueChange={(value) => { setLanguage(value) saved("언어를 변경했습니다") }} > <SelectTrigger className="w-full"> <SelectValue /> </SelectTrigger> <SelectContent position="popper"> <SelectItem value="ja">일본어</SelectItem> <SelectItem value="en">English</SelectItem> </SelectContent> </Select> </div> <DeleteAccount /> </div> ) } // ② 되돌릴 수 없는 작업은 확인 대화상자에서 '삭제'를 입력한 뒤에 실행한다 function DeleteAccount() { const [open, setOpen] = useState(false) const [confirmText, setConfirmText] = useState("") function onDelete() { setOpen(false) setConfirmText("") toast.success("계정을 삭제했습니다(데모라서 실제로는 삭제되지 않습니다)") } return ( <div className="rounded-lg border border-destructive/30 p-4"> <p className="text-sm font-medium text-destructive">계정 삭제</p> <p className="mt-1 text-xs text-muted-foreground">모든 데이터가 삭제되며 되돌릴 수 없습니다</p> <Dialog open={open} onOpenChange={(isOpen) => { setOpen(isOpen) // 닫으면 입력한 글자를 지워 둔다 if (!isOpen) setConfirmText("") }} > <DialogTrigger asChild> <Button size="sm" className="mt-3 bg-red-600 text-white hover:bg-red-700"> 계정 삭제 </Button> </DialogTrigger> <DialogContent className="p-6"> <DialogHeader> <DialogTitle className="text-lg font-semibold">정말 삭제하시겠습니까?</DialogTitle> <DialogDescription> 모든 데이터가 삭제되며 되돌릴 수 없습니다. 계속하려면 '삭제'를 입력해 주세요. </DialogDescription> </DialogHeader> <Input value={confirmText} onChange={(e) => setConfirmText(e.target.value)} placeholder="삭제" /> <DialogFooter className="mx-0 mb-0 border-t-0 bg-transparent p-0 pt-2"> <DialogClose asChild> <Button variant="outline">취소</Button> </DialogClose> <Button className="bg-red-600 text-white hover:bg-red-700" disabled={confirmText !== "삭제"} onClick={onDelete}> 삭제 </Button> </DialogFooter> </DialogContent> </Dialog> </div> ) } function NotificationsTab() { const [email, setEmail] = useState(true) const [push, setPush] = useState(false) const [weekly, setWeekly] = useState(true) return ( <div> <div className="divide-y"> <SettingRow title="이메일 알림" description="소식을 이메일로 받기"> <Switch checked={email} onCheckedChange={(on) => { setEmail(on) saved(on ? "이메일 알림을 켰습니다" : "이메일 알림을 껐습니다") }} /> </SettingRow> <SettingRow title="푸시 알림" description="휴대폰에 알림 표시"> <Switch checked={push} onCheckedChange={(on) => { setPush(on) saved(on ? "푸시 알림을 켰습니다" : "푸시 알림을 껐습니다") }} /> </SettingRow> <SettingRow title="주간 요약" description="매주 월요일에 지난 1주일 요약 보내기"> <Switch checked={weekly} onCheckedChange={(on) => { setWeekly(on) saved(on ? "주간 요약을 켰습니다" : "주간 요약을 껐습니다") }} /> </SettingRow> </div> <p className="mt-3 text-xs text-muted-foreground">변경 사항은 바로 저장됩니다</p> </div> ) } function DisplayTab() { const [theme, setTheme] = useState("light") // 선택한 테마를 이 화면에 적용한다(dark라는 class를 붙였다 뗐다 한다) function applyTheme(value: string) { const dark = value === "dark" || (value === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches) document.documentElement.classList.toggle("dark", dark) } return ( <SettingRow title="테마" description="라이트, 다크 또는 시스템 설정에 맞추기"> <Select value={theme} onValueChange={(value) => { setTheme(value) applyTheme(value) saved("테마를 변경했습니다") }} > <SelectTrigger className="w-40"> <SelectValue /> </SelectTrigger> <SelectContent position="popper"> <SelectItem value="light">라이트</SelectItem> <SelectItem value="dark">다크</SelectItem> <SelectItem value="system">시스템 설정에 맞추기</SelectItem> </SelectContent> </Select> </SettingRow> ) } export default function App() { return ( <div className="mx-auto my-6 w-full max-w-md rounded-xl border bg-card p-6 text-card-foreground"> <p className="text-lg font-semibold">설정</p> {/* ③ 탭으로 설정 종류별로 화면을 나눈다 */} <Tabs defaultValue="notifications" className="mt-4"> <TabsList className="grid w-full grid-cols-3"> <TabsTrigger value="account">계정</TabsTrigger> <TabsTrigger value="notifications">알림</TabsTrigger> <TabsTrigger value="display">표시</TabsTrigger> </TabsList> <TabsContent value="account" className="mt-5"> <AccountTab /> </TabsContent> <TabsContent value="notifications" className="mt-3"> <NotificationsTab /> </TabsContent> <TabsContent value="display" className="mt-3"> <DisplayTab /> </TabsContent> </Tabs> <Toaster position="bottom-right" /> </div> ) }