設定画面
タブで設定の種類を分け、スイッチや選択は押した瞬間に保存します。アカウントの削除だけは、確認ダイアログで念を押します。
設定画面
ここがポイント
- 1
すぐ保存する設定には、保存ボタンを置かない
スイッチは押した瞬間に変わるのが自然。そのかわり、保存できたことを通知で伝える
- 2
取り返しのつかない操作は、もう一度確認する
削除の前に確認ダイアログを出し、「削除」と打つまでボタンを押せなくする。うっかり消すのを防ぐ
- 3
設定が多いときは、タブで分ける
アカウント・通知・表示のように種類ごとに分けると、1画面が短くなって探しやすい
使った道具
押すと、第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" // 設定の1行:左に名前と説明、右にスイッチなど 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> ) }