設定

CursorやClaudeのように構成した、AIプロダクト向けの設定です。検索、グループ、外部リンクを備えた塗りつぶしのサイドバー、控えめなピッカーと入れ子のオプションを持つ行のカード、変更があったときだけ浮かび上がる暗い保存アイランド、⌘Sでの保存、チェックやサーバーからのフィールドエラー、コンテンツの形に合わせた読み込み状態を備えています。

Cursor、Claude、Codexはどれも同じ設定ページに落ち着きました。検索といくつかのグループ化されたセクションを持つ塗りつぶしのサイドバーと、右側にある、左にラベルと説明、右に控えめなコントロールを置いた行のカードです。Settingsはそのページです。セクションを保持し、どの設定ページでもうまくいかない点を処理します。編集内容の消失、二重保存、デスクトップのサイドバーからスマートフォンのリストへの切り替えです。

行には任意のコントロールを置けます。SettingsSelectはそれらのアプリが使うコンパクトな値のピッカーで、選択肢のメニューを開く小さなアウトラインのボタンです。SettingsNumberは押し続けると繰り返されるステッパーです。どちらも行にちなんだ名前が付くため、スクリーンリーダーは「Chat font, Serif」と読み上げます。SettingsLinkは別の何かを開く行で、アプリを離れるリンクにはシェブロンまたは矢印が付きます。SettingsNestedは、Run codeの下のネットワークアクセスのように、スイッチの下に依存オプションをスライドして開きます。Searchはラベル、説明、キーワードでサイドバーを絞り込み、Enterで最初の一致を開きます。SettingsChoiceは選択肢を画像カードにするため、見た目でテーマや密度を選べます。

指示するまで何も保存されません。値が保存済みのものと異なった時点で、DiscardとSaveを備えた暗いアイランドが下から浮かび上がり、サイドバーのそのセクションの項目にドットが付きます。元に戻すとバーは消えます。別のセクションを開こうとしたり、スマートフォンで戻ったり、タブを閉じようとしたりすると、切り替えはブロックされます。バーが揺れて先に保存または破棄するよう伝え、ブラウザーはタブを閉じる前に確認します。⌘SまたはCtrl+Sで、どこからでも保存できます。

保存すると、ボタンに進捗が表示され、その後アイランドが縮んでSavedのチェックになり、スライドして消えます。チェックに失敗した場合は、フィールドにエラーが表示され、フォーカスが最初のフィールドに移り、バーが修正すべき数を伝えます。サーバーが拒否した場合は、フィールドのエラーを返すかエラーを投げると、下書きは入力したままの状態で残ります。保存中に入力を続けると、新しい編集のためにバーは表示されたままになります。

スマートフォンでは、サイドバーが説明とシェブロンを持つグループ化されたリストになります。セクションをタップすると、戻るボタンとともにリストの上にスライドインし、フォーカスが見出しに移ります。セクションのデータを読み込む間は、スイッチの行の形をしたスケルトン、またはskeletonプロップで渡した独自のものが表示され、読み込みに失敗した場合はTry again付きのエラーが表示されます。

  1. Proレジストリをcomponents.jsonに追加する

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  2. トークンを追加する

    アカウントページでトークンを作成し、.env.local に HEXTAUI_PRO_TOKEN として設定してください。

  3. ブロックを追加する

    pnpm dlx shadcn@latest add @hextaui-pro/settings

セクションをAPIに接続する

useSettingsFormは、渡した値の下書きを保持します。フィールドの下にエラーを表示するにはonSaveからフィールドエラーを返し、保存バーにメッセージを表示するにはエラーを投げます。どちらの場合も下書きは保持されます。

"use client"

import { IconAdjustmentsHorizontal, IconUserCircle } from "@tabler/icons-react"

import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"

import {
  SettingsGroup,
  SettingsRow,
  SettingsSection,
  SettingsShell,
  useSettingsForm,
  type SettingsSectionItem,
} from "@/components/blocks/settings/settings"

const sections: SettingsSectionItem[] = [
  { id: "profile", label: "Profile", icon: <IconUserCircle />, group: "Account" },
  { id: "preferences", label: "Preferences", icon: <IconAdjustmentsHorizontal />, group: "Account" },
]

type Profile = { name: string; digest: boolean }

function ProfileForm({ profile }: { profile: Profile }) {
  const form = useSettingsForm({
    values: profile,
    validate: (values) => (values.name.trim() ? {} : { name: "Enter your name." }),
    onSave: async (values) => {
      const response = await fetch("/api/profile", {
        method: "PATCH",
        body: JSON.stringify(values),
      })
      if (response.status === 409) return { name: "That name is taken." }
      if (!response.ok) throw new Error("Check your connection and try again.")
    },
  })

  return (
    <SettingsGroup>
      <SettingsRow label="Name" error={form.errors.name}>
        <Input
          value={form.values.name}
          onChange={(event) => form.setValue("name", event.target.value)}
          className="@md/field-group:w-64"
        />
      </SettingsRow>
      <SettingsRow label="Weekly digest" description="A summary every Monday." layout="inline">
        <Switch
          checked={form.values.digest}
          onCheckedChange={(checked) => form.setValue("digest", checked)}
        />
      </SettingsRow>
    </SettingsGroup>
  )
}

export function SettingsPage({ profile }: { profile: Profile }) {
  return (
    <SettingsShell sections={sections} className="h-svh">
      <SettingsSection id="profile">
        <ProfileForm profile={profile} />
      </SettingsSection>
      <SettingsSection id="preferences">{null}</SettingsSection>
    </SettingsShell>
  )
}

セクションごとに1つのルート

valueとonValueChangeでアクティブなセクションを制御すると、各セクションに専用のURLを持たせられます。未保存の変更がある間はシェルが切り替えをブロックするため、onValueChangeは離れても安全なときにだけ呼ばれます。

"use client"

import { usePathname, useRouter } from "next/navigation"

import {
  SettingsSection,
  SettingsShell,
  type SettingsSectionItem,
} from "@/components/blocks/settings/settings"

const sections: SettingsSectionItem[] = [
  { id: "profile", label: "Profile", group: "Account" },
  { id: "billing", label: "Billing", group: "Workspace" },
]

export function SettingsLayout({ children }: { children: React.ReactNode }) {
  const router = useRouter()
  const section = usePathname().split("/").at(-1) ?? "profile"

  return (
    <SettingsShell
      sections={sections}
      value={section}
      onValueChange={(id) => router.push(`/settings/${id}`)}
      className="h-svh"
    >
      <SettingsSection id={section}>{children}</SettingsSection>
    </SettingsShell>
  )
}

読み込みとエラー

セクションのデータを読み込む間、statusを渡します。スケルトンは150ms待つため、高速な読み込みでちらつくことはなく、エラー状態はonRetryを通じてTry againを提示します。

"use client"

import * as React from "react"

import { Switch } from "@/components/ui/switch"

import { SettingsGroup, SettingsRow, SettingsSection, useSettingsForm } from "@/components/blocks/settings/settings"

type Alerts = { invoices: boolean; overage: boolean }

function AlertsForm({ alerts }: { alerts: Alerts }) {
  const form = useSettingsForm({
    values: alerts,
    onSave: async (values) => {
      const response = await fetch("/api/alerts", { method: "PUT", body: JSON.stringify(values) })
      if (!response.ok) throw new Error("Try again in a moment.")
    },
  })

  return (
    <SettingsGroup>
      <SettingsRow label="Invoices" layout="inline">
        <Switch
          checked={form.values.invoices}
          onCheckedChange={(checked) => form.setValue("invoices", checked)}
        />
      </SettingsRow>
      <SettingsRow label="Usage over 80%" layout="inline">
        <Switch
          checked={form.values.overage}
          onCheckedChange={(checked) => form.setValue("overage", checked)}
        />
      </SettingsRow>
    </SettingsGroup>
  )
}

export function AlertsSection() {
  const [alerts, setAlerts] = React.useState<Alerts | null>(null)
  const [failed, setFailed] = React.useState(false)

  const load = React.useCallback(() => {
    setFailed(false)
    fetch("/api/alerts")
      .then((response) => (response.ok ? response.json() : Promise.reject(response)))
      .then(setAlerts)
      .catch(() => setFailed(true))
  }, [])

  React.useEffect(load, [load])

  return (
    <SettingsSection
      id="alerts"
      status={failed ? "error" : alerts ? "ready" : "loading"}
      onRetry={load}
    >
      {alerts ? <AlertsForm alerts={alerts} /> : null}
    </SettingsSection>
  )
}

構造

外側から内側へ組み合わせるパーツ。

パーツ説明
SettingsShellページ。セクションのナビゲーション、コンテンツ列、保存バー、未保存の変更を残して離れることに対するガード。
SettingsSection1つのセクション。開いている間だけ描画され、見出し、任意のアクション、読み込みとエラーの状態を備えます。
SettingsGroupタイトルつきの行のカード。グループに関するメモを書く任意のフッターがあります。
SettingsRowスクリーンリーダー向けに連携されたラベル、説明、コントロールと、その下のフィールドエラー。
SettingsSelect短いリストから1つの値を選ぶ、控えめなピッカー。
SettingsLinkページ、ダイアログ、外部リンクを開く行。
SettingsNested親のスイッチがオンの間、スライドして開く依存オプション。
SettingsNumber押し続けると繰り返される−と+を備えた数値ステッパーで、Base UIのNumber Fieldの上に作られています。
SettingsChoiceテーマや密度のような、1つのオプションを選ぶための画像カード。radioのセマンティクスを持ちます。
SettingsSkeleton読み込み中のプレースホルダー。グループごとの行数とコントロールの形を設定できます。
useSettingsForm1つのセクションの下書き。変更を追跡し、検証し、保存し、セクションを保存バーに接続します。
useSettingsNavigateコンテンツ内からセクションを開きます。サイドバーと同様に保護されています。

SettingsShell

すべてのdivプロップも受け付けます。

プロパティ型デフォルト
sections{ id, label, description?, icon?, group?, keywords?, href? }。同じgroupを持つ連続した項目は、見出しを共有します。keywordsは検索でセクションを見つけやすくし、hrefはその項目を外部リンクにします。
SettingsSectionItem[]–
value制御する場合に開いているセクション。
string–
defaultValue最初に開いているセクション。
stringfirst section
onValueChange別のセクションが開かれたときに呼ばれます。未保存の変更がある間や保存中は呼ばれません。
(value: string) => void–
titleナビゲーションの上のページ見出しと、スマートフォンでの戻るボタンのラベル。
ReactNode"Settings"
descriptionタイトルの下の行。
ReactNode–
navHeaderサイドバーの上部にあるコンテンツ。アプリへのBackリンクなど。
ReactNode–
searchableセクションの上に検索フィールドを追加します。
booleanfalse
navFooterサイドバーの下部に固定されるコンテンツ。サインイン中のユーザーなど。
ReactNode–
groupLabels各グループの名前をその上に表示します。オフにすると、グループは余白だけで区切られます。名前は引き続きスクリーンリーダー向けにグループのラベルとして使われます。
booleantrue

SettingsSection

すべてのsectionプロップも受け付けます。

プロパティ型デフォルト
idsections内のidと一致させます。
string–
title見出し。
ReactNodethe section's label
description見出しの下の行。
ReactNodethe section's description
actions見出しの横のボタン。
ReactNode–
status子要素の代わりに、スケルトンまたはエラーを表示します。
"ready" | "loading" | "error""ready"
skeletonstatusがloadingの間に表示する内容。
ReactNode<SettingsSkeleton />
errorエラー状態のメッセージ。
ReactNode–
onRetryエラー状態にTry againを追加します。
() => void–
プロパティ型デフォルト
titleカードの上の見出し。
ReactNode–
description見出しの下にある、グループの内容を説明する控えめな行。
ReactNode–
footerカードの下部にある、変更の影響などのメモを書く控えめな帯。
ReactNode–
プロパティ型デフォルト
label行の内側のコントロールにラベルを付けます。
ReactNode–
descriptionコントロールとともに読み上げられるヘルプテキスト。
ReactNode–
errorコントロールを無効な値としてマークし、行の下にメッセージを表示します。
string–
layoutautoは、カードが広いときはコントロールをラベルの横に、狭いときは下に置きます。inlineはスイッチ向けに、常にラベルの横に置きます。stackedはテキストエリア向けに、常に下に置きます。
"auto" | "inline" | "stacked""auto"
disabled行のフィールドを無効にします。
booleanfalse

SettingsSelect

すべてのButtonプロップも受け付けます。

プロパティ型デフォルト
value選択された値。
string–
onValueChange新しい値とともに呼ばれます。
(value: string) => void–
options選択肢を、順番に。
{ value, label }[]–

SettingsChoice

radio groupなので、矢印キーでカード間を移動できます。Base UIのRadioGroupのすべてのプロップも受け付けます。

プロパティ型デフォルト
value選択されたオプション。
string–
onValueChange新しいオプションとともに呼ばれます。
(value: string) => void–
options各カードの画像と、その下の名前。
{ value, label, preview }[]–
columns1行あたりのカード数。行が狭いと、4は2に減ります。
2 | 3 | 43
ratio16:10のプレビュー、または短いものには2:1。
"card" | "wide""card"

SettingsNumber

formatやsmallStepなど、Base UIのNumberField.Rootのすべてのプロップも受け付けます。

プロパティ型デフォルト
value現在の数値。
number | null–
onValueChange数値が変わるたびに呼ばれます。
(value: number | null) => void–
min最小値。−ボタンはそこで無効になります。
number–
max最大値。+ボタンはそこで無効になります。
number–
step1回の押下または矢印キーでどれだけ変化するか。
number1

すべてのanchorプロップも受け付けます。hrefがない場合はbuttonとして描画されます。

プロパティ型デフォルト
label行のタイトル。
ReactNode–
descriptionタイトルの下の行。
ReactNode–
externalhrefを新しいタブで開き、シェブロンの代わりに矢印を表示します。
booleanfalse
プロパティ型デフォルト
openオプションを表示します。通常は親のスイッチの値です。
boolean–
プロパティ型デフォルト
groups各プレースホルダーグループが持つ行数。
number[][3, 2]
control各行の右側に表示される形。
"switch" | "select" | "input""switch"

useSettingsForm

{ values, setValue, errors, dirty, status, save, discard } を返します。

プロパティ型デフォルト
values現在保存されている内容。変更があり、編集がない場合、下書きはそれに追従します。
Values–
onSave下書きを保存します。フィールドエラーを表示するには { field: message } を返し、保存バーにメッセージを表示するにはエラーを投げます。
(values) => void | errors | Promise<void | errors>–
validateonSaveの前に実行されます。エラーがあれば保存を中止し、最初の無効なフィールドにフォーカスします。
(values) => errors | undefined–

useSettingsNavigate

シェル内のどこからでもセクションを開く関数を返します。バナーのOpenボタンなどに使えます。サイドバーと同様に、未保存の変更を尊重します。

プロパティ型デフォルト
navigateセクションを開きます。未保存の変更がある場合は、保存バーを揺らします。
(id: string) => void–
キーアクション
Tabナビゲーション、セクション、開いている場合は保存バーの順に移動します。
Enterフォーカスのあるセクションを開きます。
↑↓ステッパーで、数値を1ステップ変更します。Shiftでは10ずつ変化します。
Enter検索フィールドで、最初に一致したセクションを開きます。Escapeで検索をクリアします。
⌘S未保存の変更があるときに保存します。WindowsとLinuxではCtrl+Sです。
  • ナビゲーションはランドマークで、開いているセクションは現在のページとしてマークされます。
  • 各セクションは、見出しにちなんだ名前のリージョンです。スマートフォンでは、セクションが開くとフォーカスが見出しに移り、戻ると元の行に戻ります。
  • 行はFieldを使うため、ラベル、説明、エラーはコントロールに関連付けられます。
  • ブロックされたナビゲーションはpoliteに読み上げられ、保存の失敗はalertとして読み上げられます。
  • 保存バーと非表示のパネルはinertであるため、タブ順から外れ、スクリーンリーダーからも隠されます。
  • モーション軽減時は、パネルはスライドではなくフェードし、保存バーの揺れはリングに変わります。

使用技術

Settings を構成する無料のHextaUIコンポーネントです。それぞれ単独でインストールできます。

コード

6 個のファイルを components/blocks/settings に追加しました。