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
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.jsonAI 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;
}