설정 화면

탭으로 설정을 나누고, 스위치와 선택 메뉴는 누르는 순간 저장합니다. 계정 삭제만 확인 대화 상자로 한 번 더 확인합니다.

설정

핵심 포인트

  1. 1

    바로 저장되는 설정에는 저장 버튼을 두지 않기

    스위치는 누른 순간 바뀌는 것이 자연스럽습니다. 대신 저장했다는 것을 알림으로 알려 줍니다.

  2. 2

    되돌릴 수 없는 작업은 한 번 더 확인하기

    삭제 전에 확인 대화 상자를 띄우고, 사용자가 "삭제"를 입력할 때까지 버튼을 누를 수 없게 합니다. 실수로 지우는 것을 막을 수 있습니다.

  3. 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>
  )
}