jsp-dsv0.1

User settingspage

Profile, account and notifications in the app shell, with a settings nav on the left.

Pages
Preview
/preview/settings
Guidance
draft · v0.1.0

Preview

The real page, full screen, in a frame. Switch width here and mode at the foot of the side nav.

Guidance

Built from

Code

Copy it into a product and change the words; the layout and tokens carry over.

app/preview/settings/page.tsx
import type * as React from "react";
import { Text } from "@/registry/jsp/text";
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/registry/ui/alert-dialog";
import { Avatar, AvatarFallback } from "@/registry/ui/avatar";
import { Badge } from "@/registry/ui/badge";
import { Button } from "@/registry/ui/button";
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldGroup,
  FieldLabel,
} from "@/registry/ui/field";
import { Input } from "@/registry/ui/input";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/registry/ui/select";
import { Switch } from "@/registry/ui/switch";
import { Bell, ShieldCheck, UserRound } from "lucide-react";
import { AppShell } from "../_shell";
import { SettingsLayout } from "./settings-layout";

export const metadata = { title: "Settings preview – jsp-ds" };

/** A settings group: what it is on the left, its fields on the right (the Settings section pattern). */
function Group({
  id,
  title,
  description,
  children,
}: {
  id: string;
  title: string;
  description: string;
  children: React.ReactNode;
}) {
  return (
    <section
      aria-labelledby={id}
      className="grid grid-cols-1 gap-component-xl border-b border-border-subtle py-layout-sm first:pt-0 last:border-b-0 xl:grid-cols-3"
    >
      <div className="flex flex-col gap-component-xs">
        <Text as="h3" id={id} textStyle="heading-5">
          {title}
        </Text>
        <Text textStyle="body-sm" color="subtle">
          {description}
        </Text>
      </div>
      <div className="flex flex-col gap-component-lg xl:col-span-2">
        {children}
      </div>
    </section>
  );
}

/**
 * User settings inside the app shell, with a left settings nav: Profile, Account, Notifications. Text fields save
 * with a button per group; switches save as they change; the destructive action is last, behind an Alert dialog.
 */
export default function SettingsPage() {
  return (
    <AppShell current="Settings">
      <div className="flex flex-col gap-component-xs">
        <Text as="h1" textStyle="heading-2">
          Settings
        </Text>
        <Text color="subtle">Your profile, sign-in and notifications.</Text>
      </div>

      <SettingsLayout
        sections={[
          {
            id: "profile",
            label: "Profile",
            icon: <UserRound />,
            description: "How you appear to people you invite.",
            content: (
              <>
                <form>
                  <Group
                    id="settings-photo"
                    title="Photo"
                    description="Shown beside your comments."
                  >
                    <div className="flex items-center gap-component-md">
                      <Avatar className="size-control-lg">
                        <AvatarFallback>JP</AvatarFallback>
                      </Avatar>
                      <Button variant="outline" type="button">
                        Upload photo
                      </Button>
                      <Button variant="ghost" type="button">
                        Remove
                      </Button>
                    </div>
                  </Group>
                  <Group
                    id="settings-profile"
                    title="Details"
                    description="Shown to people you invite to review."
                  >
                    <FieldGroup>
                      <Field>
                        <FieldLabel htmlFor="settings-name">Name</FieldLabel>
                        <Input
                          id="settings-name"
                          autoComplete="name"
                          defaultValue="Jay Price"
                        />
                      </Field>
                      <Field>
                        <FieldLabel htmlFor="settings-role">Role</FieldLabel>
                        <Input
                          id="settings-role"
                          defaultValue="Service designer"
                        />
                      </Field>
                      <Field>
                        <FieldLabel htmlFor="settings-tz">Time zone</FieldLabel>
                        <Select defaultValue="kl">
                          <SelectTrigger id="settings-tz" className="w-full">
                            <SelectValue />
                          </SelectTrigger>
                          <SelectContent>
                            <SelectItem value="london">London (GMT)</SelectItem>
                            <SelectItem value="kl">
                              Kuala Lumpur (GMT+8)
                            </SelectItem>
                          </SelectContent>
                        </Select>
                      </Field>
                    </FieldGroup>
                    <div className="flex gap-component-xs">
                      <Button type="submit">Save profile</Button>
                    </div>
                  </Group>
                </form>
              </>
            ),
          },
          {
            id: "account",
            label: "Account",
            icon: <ShieldCheck />,
            description: "Email, password and where you're signed in.",
            content: (
              <>
                <Group
                  id="settings-email"
                  title="Email"
                  description="Where sign-in links and notifications go."
                >
                  <Field>
                    <FieldLabel htmlFor="settings-email-input">
                      Email
                    </FieldLabel>
                    <Input
                      id="settings-email-input"
                      type="email"
                      autoComplete="email"
                      defaultValue="jay@example.com"
                    />
                    <FieldDescription>
                      We&rsquo;ll send a link to confirm a new address.
                    </FieldDescription>
                  </Field>
                  <div>
                    <Button>Change email</Button>
                  </div>
                </Group>
                <Group
                  id="settings-password"
                  title="Password"
                  description="At least 12 characters."
                >
                  <FieldGroup>
                    <Field>
                      <FieldLabel htmlFor="settings-current">
                        Current password
                      </FieldLabel>
                      <Input
                        id="settings-current"
                        type="password"
                        autoComplete="current-password"
                      />
                    </Field>
                    <Field>
                      <FieldLabel htmlFor="settings-new">
                        New password
                      </FieldLabel>
                      <Input
                        id="settings-new"
                        type="password"
                        autoComplete="new-password"
                      />
                    </Field>
                  </FieldGroup>
                  <div>
                    <Button>Update password</Button>
                  </div>
                </Group>
                <Group
                  id="settings-sessions"
                  title="Signed in"
                  description="Devices signed in to your account."
                >
                  <ul className="flex flex-col">
                    {[
                      ["MacBook Pro · Chrome", "Kuala Lumpur · now", true],
                      ["iPhone · Safari", "Kuala Lumpur · 2 hours ago", false],
                    ].map(([device, where, current]) => (
                      <li
                        key={String(device)}
                        className="flex items-center justify-between gap-component-md border-b border-border-subtle py-component-sm last:border-b-0"
                      >
                        <div className="flex flex-col">
                          <Text as="span" textStyle="label">
                            {device}
                          </Text>
                          <Text as="span" textStyle="caption" color="subtle">
                            {where}
                          </Text>
                        </div>
                        {current ? (
                          <Badge variant="secondary">This device</Badge>
                        ) : (
                          <Button variant="outline" size="sm">
                            Sign out
                          </Button>
                        )}
                      </li>
                    ))}
                  </ul>
                </Group>
                <Group
                  id="settings-delete"
                  title="Delete account"
                  description="Deletes your account and the reviews you own. This can't be undone."
                >
                  <div>
                    <AlertDialog>
                      <AlertDialogTrigger asChild>
                        <Button variant="destructive">Delete account</Button>
                      </AlertDialogTrigger>
                      <AlertDialogContent>
                        <AlertDialogHeader>
                          <AlertDialogTitle>
                            Delete your account?
                          </AlertDialogTitle>
                          <AlertDialogDescription>
                            Your 3 projects and their comments will be deleted
                            for everyone. You can&rsquo;t undo this.
                          </AlertDialogDescription>
                        </AlertDialogHeader>
                        <AlertDialogFooter>
                          <AlertDialogCancel>Keep account</AlertDialogCancel>
                          <AlertDialogAction variant="destructive">
                            Delete account
                          </AlertDialogAction>
                        </AlertDialogFooter>
                      </AlertDialogContent>
                    </AlertDialog>
                  </div>
                </Group>
              </>
            ),
          },
          {
            id: "notifications",
            label: "Notifications",
            icon: <Bell />,
            description: "What we email you about.",
            content: (
              <>
                <Group
                  id="settings-notify"
                  title="Email notifications"
                  description="Changes save as you make them."
                >
                  <FieldGroup>
                    {[
                      [
                        "notify-comments",
                        "New comments",
                        "On screens you own.",
                        true,
                      ],
                      [
                        "notify-signoff",
                        "Sign-off requests",
                        "When a client asks you to sign off.",
                        true,
                      ],
                      [
                        "notify-digest",
                        "Weekly summary",
                        "Every Monday at 9:00.",
                        false,
                      ],
                    ].map(([id, label, help, on]) => (
                      <Field
                        key={String(id)}
                        orientation="horizontal"
                        className="justify-between"
                      >
                        <FieldContent>
                          <FieldLabel htmlFor={String(id)}>{label}</FieldLabel>
                          <FieldDescription>{help}</FieldDescription>
                        </FieldContent>
                        <Switch id={String(id)} defaultChecked={Boolean(on)} />
                      </Field>
                    ))}
                  </FieldGroup>
                </Group>
              </>
            ),
          },
        ]}
      />
    </AppShell>
  );
}
app/preview/settings/settings-layout.tsx
"use client"

import * as React from "react"
import { cn } from "cn"
import { Text } from "@/registry/jsp/text"

export interface SettingsSection {
  id: string
  label: string
  /** A rendered lucide icon, e.g. <UserRound />; sized here. */
  icon: React.ReactNode
  description: string
  content: React.ReactNode
}

/**
 * Settings with a left nav: one link per section, the current one marked with aria-current="page".
 * In a product each link is its own route (/settings/profile …); here they switch in place so the preview
 * works on its own. From md the nav is a column beside the content; below md it's a row that scrolls.
 */
export function SettingsLayout({ sections }: { sections: SettingsSection[] }) {
  const [current, setCurrent] = React.useState(sections[0].id)
  const heading = React.useRef<HTMLHeadingElement>(null)
  const section = sections.find((s) => s.id === current) ?? sections[0]

  return (
    <div className="flex flex-col gap-layout-sm md:flex-row md:gap-layout-lg">
      <nav aria-label="Settings" className="shrink-0 md:w-site-xs md:max-w-[30%]">
        <ul className="-mx-component-xs flex gap-component-3xs overflow-x-auto px-component-xs pb-component-xs md:mx-0 md:flex-col md:overflow-visible md:px-0 md:pb-0">
          {sections.map((s) => {
            const active = s.id === current
            return (
              <li key={s.id} className="shrink-0">
                <a
                  href={`#${s.id}`}
                  aria-current={active ? "page" : undefined}
                  onClick={(e) => {
                    e.preventDefault()
                    setCurrent(s.id)
                    requestAnimationFrame(() => heading.current?.focus())
                  }}
                  className={cn(
                    "flex h-control-sm items-center gap-component-xs rounded-control px-component-sm whitespace-nowrap text-text-subtle hover:bg-surface-muted hover:text-text-default focus-visible:outline-2 focus-visible:outline-focus-ring",
                    active && "bg-surface-muted text-text-default",
                  )}
                >
                  <span aria-hidden className="flex [&_svg]:size-icon-sm">
                    {s.icon}
                  </span>
                  <Text as="span" textStyle="body-sm" color={active ? "default" : "subtle"} className={active ? "font-medium" : undefined}>
                    {s.label}
                  </Text>
                </a>
              </li>
            )
          })}
        </ul>
      </nav>

      <div className="flex min-w-0 flex-1 flex-col gap-component-lg">
        <div className="flex flex-col gap-component-3xs border-b border-border-subtle pb-component-lg">
          <Text asChild textStyle="heading-4">
            <h2 ref={heading} tabIndex={-1} className="outline-none">
              {section.label}
            </h2>
          </Text>
          <Text textStyle="body-sm" color="subtle">
            {section.description}
          </Text>
        </div>
        <div key={section.id}>{section.content}</div>
      </div>
    </div>
  )
}