← All Artifacts

Account Settings · Block

Adaptive Account Settings

Controlled settings that read as a form on Desktop and an editable section list on Touch.

Free SurfaceMIT sourceGenerated from Catalog index
Live source preview

Open the live preview in a new page →

Install

One standard shadcn command

The Consumer receives editable source in their application. The Catalog is not a runtime dependency.

pnpm dlx shadcn@latest add https://shadcnpwa.pages.dev/registry/items/adaptive-account-settings/registry.json

AI handoff

Prompt for your coding agent

Install Adaptive Account Settings from the public shadcn registry, then adapt the owned source to my product. Keep the Desktop and Touch presentations, preserve keyboard and screen-reader behavior, and connect my controlled data and actions without adding network requests or persistence.

Full source

Inspect before you install.

Neutral preferences with Consumer-owned values and change handling.

// src/adaptive-account-settings.tsx
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Sheet } from "@/components/ui/sheet";
import { useAdaptivePresentation, type Presentation } from "./use-adaptive-presentation";
import "./adaptive-artifact.css";

export type SettingValue = string | boolean;

export interface SettingItem {
  id: string;
  label: string;
  description?: string;
  type?: "text" | "toggle" | "select";
  options?: readonly { label: string; value: string }[];
}

export interface SettingSection {
  id: string;
  title: string;
  items: readonly SettingItem[];
}

export interface AdaptiveAccountSettingsProps {
  sections: readonly SettingSection[];
  values: Readonly<Record<string, SettingValue>>;
  onChange: (id: string, value: SettingValue) => void;
  onSave?: () => void;
  presentationOverride?: Presentation;
  copy?: { editLabel?: string; closeLabel?: string; saveLabel?: string };
}

function SettingControl({
  item,
  value,
  onChange,
}: {
  item: SettingItem;
  value: SettingValue;
  onChange: (value: SettingValue) => void;
}) {
  const type = item.type ?? "text";
  if (type === "toggle") {
    return (
      <input
        type="checkbox"
        checked={Boolean(value)}
        onChange={(event) => onChange(event.target.checked)}
      />
    );
  }
  if (type === "select") {
    return (
      <select value={String(value)} onChange={(event) => onChange(event.target.value)}>
        {item.options?.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
      </select>
    );
  }
  return <input value={String(value ?? "")} onChange={(event) => onChange(event.target.value)} />;
}

export function AdaptiveAccountSettings({
  sections,
  values,
  onChange,
  onSave,
  presentationOverride,
  copy,
}: AdaptiveAccountSettingsProps) {
  const presentation = useAdaptivePresentation(presentationOverride);
  const [editingId, setEditingId] = useState<string | null>(null);
  const [draft, setDraft] = useState<SettingValue>("");
  const labels = {
    edit: copy?.editLabel ?? "Edit",
    close: copy?.closeLabel ?? "Close setting editor",
    save: copy?.saveLabel ?? "Save changes",
  };
  const items = sections.flatMap((section) => section.items);
  const editingItem = items.find((item) => item.id === editingId);

  useEffect(() => {
    if (editingId) setDraft(values[editingId] ?? "");
  }, [editingId, values]);

  if (!presentation) return <div className="adaptive-card" aria-busy="true">Loading settings…</div>;
  const isTouch = presentation === "touch";

  const update = (id: string, value: SettingValue) => onChange(id, value);

  return (
    <section className="adaptive-surface adaptive-grid" data-presentation={presentation}>
      {sections.map((section) => (
        <section className="adaptive-card" key={section.id} aria-labelledby={`settings-${section.id}`}>
          <h2 id={`settings-${section.id}`}>{section.title}</h2>
          {isTouch ? (
            <ul className="adaptive-list">
              {section.items.map((item) => (
                <li key={item.id}>
                  <Button
                    className="adaptive-button"
                    type="button"
                    onClick={() => setEditingId(item.id)}
                    aria-label={`${labels.edit}: ${item.label}`}
                  >
                    <span><strong>{item.label}</strong>{item.description && <small className="adaptive-muted">{item.description}</small>}</span>
                    <span className="adaptive-muted">{String(values[item.id] ?? "—")}</span>
                  </Button>
                </li>
              ))}
            </ul>
          ) : (
            <div className="adaptive-grid">
              {section.items.map((item) => (
                <label className="adaptive-field" key={item.id}>
                  <span><strong>{item.label}</strong>{item.description && <small className="adaptive-muted">{item.description}</small>}</span>
                  <SettingControl item={item} value={values[item.id] ?? ""} onChange={(value) => update(item.id, value)} />
                </label>
              ))}
            </div>
          )}
        </section>
      ))}
      {onSave && <Button className="adaptive-button" type="button" onClick={onSave}>{labels.save}</Button>}
      <Sheet open={editingItem !== undefined} onOpenChange={(open) => !open && setEditingId(null)}>
        {editingItem && (
          <div className="adaptive-overlay">
            <section className="adaptive-sheet adaptive-sheet--full" role="dialog" aria-modal="true" aria-labelledby="setting-editor-title">
              <div className="adaptive-sheet__header">
                <h2 id="setting-editor-title">{editingItem.label}</h2>
                <Button className="adaptive-button" type="button" aria-label={labels.close} onClick={() => setEditingId(null)}>×</Button>
              </div>
              <label className="adaptive-field">
                <span>{editingItem.description}</span>
                <SettingControl item={editingItem} value={draft} onChange={setDraft} />
              </label>
              <Button
                className="adaptive-button"
                type="button"
                onClick={() => {
                  update(editingItem.id, draft);
                  setEditingId(null);
                }}
              >
                {labels.save}
              </Button>
            </section>
          </div>
        )}
      </Sheet>
    </section>
  );
}


// src/adaptive-artifact.css
.adaptive-surface { color: var(--foreground); background: var(--background); }
.adaptive-muted { color: var(--muted-foreground); }
.adaptive-border { border-color: var(--border); }
.adaptive-button { min-height: 2.5rem; border: 1px solid var(--border); border-radius: .5rem; background: var(--background); color: var(--foreground); padding: .55rem .8rem; cursor: pointer; }
.adaptive-button:hover { background: var(--muted); }
.adaptive-button:focus-visible, .adaptive-link:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.adaptive-shell { min-height: 100%; display: grid; grid-template-columns: 16rem minmax(0, 1fr); background: var(--background); color: var(--foreground); }
.adaptive-shell[data-presentation="touch"] { display: block; padding-bottom: calc(4.75rem + env(safe-area-inset-bottom)); }
.adaptive-shell__sidebar { border-right: 1px solid var(--border); padding: 1rem; }
.adaptive-shell__sidebar nav ul, .adaptive-shell__bottom nav ul { list-style: none; margin: 1rem 0 0; padding: 0; display: grid; gap: .25rem; }
.adaptive-link { display: flex; align-items: center; gap: .6rem; border-radius: .5rem; color: inherit; text-decoration: none; padding: .65rem .7rem; }
.adaptive-link[aria-current="page"] { background: var(--accent); font-weight: 650; }
.adaptive-shell__main { min-width: 0; }
.adaptive-shell__touchbar { display: flex; justify-content: space-between; align-items: center; gap: 1rem; border-bottom: 1px solid var(--border); padding: max(.8rem, env(safe-area-inset-top)) 1rem .8rem; }
.adaptive-shell__content { min-width: 0; padding: 1rem; }
.adaptive-shell__bottom { position: fixed; z-index: 10; right: 0; bottom: 0; left: 0; border-top: 1px solid var(--border); background: color-mix(in srgb, var(--background) 94%, transparent); padding: .35rem 1rem calc(.35rem + env(safe-area-inset-bottom)); }
.adaptive-shell__bottom nav ul { display: flex; justify-content: space-around; margin: 0; }
.adaptive-shell__bottom li { min-width: 4rem; }
.adaptive-shell__bottom .adaptive-link, .adaptive-shell__bottom .adaptive-button { width: 100%; justify-content: center; flex-direction: column; gap: .1rem; border: 0; background: transparent; padding: .35rem .25rem; font-size: .72rem; }
.adaptive-overlay { position: fixed; z-index: 20; inset: 0; background: rgb(0 0 0 / .35); }
.adaptive-sheet { position: absolute; right: 0; bottom: 0; left: 0; max-height: min(80vh, 42rem); overflow: auto; border: 1px solid var(--border); border-radius: 1rem 1rem 0 0; background: var(--background); padding: 1rem; padding-bottom: calc(1rem + env(safe-area-inset-bottom)); }
.adaptive-sheet--side { top: 0; right: 0; bottom: 0; left: auto; width: min(30rem, 92vw); max-height: none; border-radius: 1rem 0 0 1rem; padding-bottom: 1rem; }
.adaptive-sheet--full { top: 0; max-height: none; border-radius: 0; padding-top: max(1rem, env(safe-area-inset-top)); }
.adaptive-sheet__header { display: flex; align-items: start; justify-content: space-between; gap: 1rem; }
.adaptive-grid { display: grid; gap: 1rem; }
.adaptive-card { border: 1px solid var(--border); border-radius: .75rem; padding: 1rem; background: var(--card, var(--background)); }
.adaptive-table { width: 100%; border-collapse: collapse; }
.adaptive-table th, .adaptive-table td { border-bottom: 1px solid var(--border); padding: .75rem .5rem; text-align: left; vertical-align: top; }
.adaptive-table th { font-size: .8rem; color: var(--muted-foreground); font-weight: 650; }
.adaptive-field { display: grid; gap: .35rem; }
.adaptive-field + .adaptive-field { margin-top: .9rem; }
.adaptive-field input, .adaptive-field select { width: 100%; min-height: 2.5rem; border: 1px solid var(--border); border-radius: .45rem; background: var(--background); color: var(--foreground); padding: .5rem .65rem; }
.adaptive-list { list-style: none; margin: 0; padding: 0; display: grid; gap: .6rem; }
.adaptive-list > li { border-bottom: 1px solid var(--border); padding: .8rem 0; }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; } }


// src/use-adaptive-presentation.ts
import { useEffect, useState } from "react";

export const PRESENTATION_BREAKPOINT = 1024;
export type Presentation = "desktop" | "touch";

export function useAdaptivePresentation(
  override?: Presentation,
): Presentation | null {
  const [presentation, setPresentation] = useState<Presentation | null>(
    override ?? null,
  );

  useEffect(() => {
    if (override) {
      setPresentation(override);
      return;
    }

    const update = () => {
      setPresentation(
        window.innerWidth >= PRESENTATION_BREAKPOINT ? "desktop" : "touch",
      );
    };
    update();

    const query = window.matchMedia(
      `(min-width: ${PRESENTATION_BREAKPOINT}px)`,
    );
    const onChange = () => update();
    if (query.addEventListener) query.addEventListener("change", onChange);
    else query.addListener(onChange);
    window.addEventListener("resize", update);
    return () => {
      window.removeEventListener("resize", update);
      if (query.removeEventListener) query.removeEventListener("change", onChange);
      else query.removeListener(onChange);
    };
  }, [override]);

  return override ?? presentation;
}