Templates
Settings
Settings in tabs; explicit save for the form, instant save for switches, a dialog for one edit, deletion confirmed by name in an alert dialog, and toasts for results.
The settings block groups four sections in tabs. Each section uses its own way of saving: fields save together, switches save one by one and at once.
Preview
Section titled “Preview”Change a field on the General tab: the save bar appears. On the Notifications tab, switches save themselves. On the Security tab, change the recovery email in a dialog and try the delete confirmation. The brand and mode switches in the page header restyle the preview too.
pnpm add @bigbang/ui| Data | Type | Note |
|---|---|---|
| General settings | { name, language, timezone, about } |
Compared with the saved value; the bar shows when they differ. |
| Notifications | boolean[] |
One request per switch change; Switch loading while it waits. |
| Recovery email | string |
Edited in a Dialog; validated on submit. |
defaultTab |
'general' | 'notifications' | 'security' | 'appearance' |
The tab selected on open. |
import { AlertDialog, Button, Card, Dialog, Heading, Inline, Radio, RadioGroup, Select, Stack, Switch, Tab, TabList, TabPanel, Tabs, Text, TextInput, Textarea, motion, useToast } from '@bigbang/ui';import { useState, type FormEvent } from 'react';import type { Lang } from '../../patterns/lang';import { AppShellTemplate } from '../../templates/app-shell/AppShellTemplate';
const copy = { tr: { title: 'Ayarlar', description: 'Çalışma alanının genel, bildirim, güvenlik ve görünüm ayarları.', sections: 'Ayar bölümleri', tabs: { general: 'Genel', notifications: 'Bildirimler', security: 'Güvenlik', appearance: 'Görünüm' }, name: 'Çalışma alanı adı', language: 'Dil', timezone: 'Saat dilimi', zones: [ { value: 'istanbul', label: 'İstanbul (GMT+3)' }, { value: 'london', label: 'Londra (GMT+0)' }, ], about: 'Açıklama', save: 'Değişiklikleri kaydet', cancel: 'Vazgeç', unsaved: 'Kaydedilmemiş değişiklikler var.', savedStatus: 'Değişiklikler kaydedildi.', notifications: ['E-posta bildirimleri', 'Haftalık özet', 'Görev atandığında'], notificationSaved: 'Bildirim ayarı kaydedildi.', lastSignIn: 'Son giriş', lastSignInValue: '28 Ekim 2026, 09:00', twoStep: 'İki adımlı doğrulama', twoStepHint: 'Girişte telefonunuza gönderilen kod da istenir.', danger: 'Tehlikeli bölge', deleteText: 'Çalışma alanı, içindeki projeler ve kayıtlarla birlikte kalıcı olarak silinir.', deleteAction: 'Çalışma alanını sil', deleteTitle: 'Çalışma alanı silinsin mi?', deleteDescription: 'Projeler ve kayıtlar da silinir. Bu işlem geri alınamaz.', confirmLabel: 'Onaylamak için çalışma alanı adını yazın', confirmMismatch: 'Ad eşleşmiyor. Çalışma alanı adını aynen yazın.', confirmDelete: 'Kalıcı olarak sil', deleted: 'Çalışma alanı silindi.', recovery: 'Kurtarma e-postası', recoveryEdit: 'Değiştir', recoveryTitle: 'Kurtarma e-postasını değiştir', recoveryHint: 'Parolanızı unutursanız sıfırlama bağlantısı bu adrese gider.', recoveryLabel: 'Yeni e-posta adresi', recoveryInvalid: 'Geçerli bir e-posta adresi girin, örneğin ad@ornek.com.', recoverySaved: 'Kurtarma e-postası güncellendi.', theme: 'Tema', themes: { light: 'Açık', dark: 'Koyu', system: 'Sistem' }, reduceMotion: 'Hareketi azalt', reduceMotionHint: 'Kayma ve ölçek hareketleri kalkar; yalnızca kısa solmalar kalır.', }, en: { title: 'Settings', description: 'General, notification, security and appearance settings of the workspace.', sections: 'Settings sections', tabs: { general: 'General', notifications: 'Notifications', security: 'Security', appearance: 'Appearance' }, name: 'Workspace name', language: 'Language', timezone: 'Time zone', zones: [ { value: 'istanbul', label: 'Istanbul (GMT+3)' }, { value: 'london', label: 'London (GMT+0)' }, ], about: 'Description', save: 'Save changes', cancel: 'Cancel', unsaved: 'You have unsaved changes.', savedStatus: 'Changes saved.', notifications: ['Email notifications', 'Weekly summary', 'When a task is assigned'], notificationSaved: 'Notification setting saved.', lastSignIn: 'Last sign-in', lastSignInValue: '28 October 2026, 09:00', twoStep: 'Two-step verification', twoStepHint: 'Sign-in also asks for a code sent to your phone.', danger: 'Danger zone', deleteText: 'The workspace is deleted for good, with its projects and records.', deleteAction: 'Delete workspace', deleteTitle: 'Delete the workspace?', deleteDescription: 'Its projects and records are deleted too. This cannot be undone.', confirmLabel: 'Type the workspace name to confirm', confirmMismatch: 'The name does not match. Type the workspace name exactly.', confirmDelete: 'Delete for good', deleted: 'Workspace deleted.', recovery: 'Recovery email', recoveryEdit: 'Change', recoveryTitle: 'Change the recovery email', recoveryHint: 'If you forget your password, the reset link goes to this address.', recoveryLabel: 'New email address', recoveryInvalid: 'Enter a valid email address, such as name@example.com.', recoverySaved: 'Recovery email updated.', theme: 'Theme', themes: { light: 'Light', dark: 'Dark', system: 'System' }, reduceMotion: 'Reduce motion', reduceMotionHint: 'Slides and scaling stop; only short fades stay.', },};
type Section = 'general' | 'notifications' | 'security' | 'appearance';type General = { name: string; language: string | null; timezone: string | null; about: string };
const WORKSPACE = 'Çalışma alanı 1';const RECOVERY = 'kullanici.a@ornek.com';const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;const initialGeneral: General = { name: WORKSPACE, language: 'tr', timezone: 'istanbul', about: '' };
export type SettingsBlockProps = { lang?: Lang; defaultTab?: Section;};
/** * Settings in tabs: explicit save for the form, instant save for switches, a Dialog for one edit, * an AlertDialog that confirms deletion by name, and toasts for the results. */export function SettingsBlock({ lang = 'tr', defaultTab = 'general' }: SettingsBlockProps) { const t = copy[lang]; return ( <AppShellTemplate lang={lang} current="settings" title={t.title} description={t.description} width="md"> <SettingsContent lang={lang} defaultTab={defaultTab} /> </AppShellTemplate> );}
// Inside the template, so `useToast` reaches the template's Toaster.function SettingsContent({ lang, defaultTab }: Required<SettingsBlockProps>) { const t = copy[lang]; const toast = useToast(); const [general, setGeneral] = useState(initialGeneral); const [saved, setSaved] = useState(initialGeneral); const [saving, setSaving] = useState<'idle' | 'saving' | 'saved'>('idle'); const [toggles, setToggles] = useState([true, false, true]); const [pendingToggle, setPendingToggle] = useState<number | null>(null); const [twoStep, setTwoStep] = useState(true); const [typed, setTyped] = useState(''); const [mismatch, setMismatch] = useState(false); const [recovery, setRecovery] = useState(RECOVERY); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(''); const [draftError, setDraftError] = useState(false);
const dirty = JSON.stringify(general) !== JSON.stringify(saved); const edit = (next: Partial<General>) => { setGeneral({ ...general, ...next }); setSaving('idle'); };
const save = (event: FormEvent) => { event.preventDefault(); setSaving('saving'); setTimeout(() => { setSaved(general); setSaving('saved'); toast.show({ title: t.savedStatus, tone: 'success' }); }, 800); };
const toggle = (index: number, checked: boolean) => { setPendingToggle(index); setTimeout(() => { setToggles((list) => list.map((value, i) => (i === index ? checked : value))); setPendingToggle(null); toast.show({ title: t.notificationSaved, tone: 'success' }); }, 800); };
const resetConfirm = (open: boolean) => { if (open) return; setTyped(''); setMismatch(false); }; // A rejected promise keeps the AlertDialog open with the error under the field. const confirmDelete = () => { if (typed.trim() !== WORKSPACE) { setMismatch(true); return Promise.reject(new Error('name mismatch')); } return new Promise<void>((resolve) => setTimeout(() => { toast.show({ title: t.deleted }); resolve(); }, 800), ); };
const openRecovery = (open: boolean) => { setEditing(open); if (open) { setDraft(recovery); setDraftError(false); } }; const saveRecovery = (event: FormEvent) => { event.preventDefault(); if (!EMAIL.test(draft.trim())) { setDraftError(true); return; } setRecovery(draft.trim()); setEditing(false); toast.show({ title: t.recoverySaved, tone: 'success' }); };
return ( <Tabs defaultValue={defaultTab} variant="line"> <TabList label={t.sections}> {(Object.keys(t.tabs) as Section[]).map((key) => ( <Tab key={key} value={key}> {t.tabs[key]} </Tab> ))} </TabList>
<TabPanel value="general"> <form onSubmit={save} className="pt-xl" aria-label={t.tabs.general}> <Stack gap="lg"> <TextInput label={t.name} name="name" required value={general.name} onValueChange={(value) => edit({ name: value })} /> <Select label={t.language} items={[{ value: 'tr', label: 'Türkçe' }, { value: 'en', label: 'English' }]} value={general.language} onValueChange={(value) => edit({ language: value })} /> <Select label={t.timezone} items={t.zones} value={general.timezone} onValueChange={(value) => edit({ timezone: value })} /> <Textarea label={t.about} necessityIndicator="optional" value={general.about} onValueChange={(value) => edit({ about: value })} /> {dirty || saving !== 'idle' ? ( <div className={['sticky bottom-0 flex flex-wrap items-center justify-between gap-md rounded-md border border-line-subtle bg-surface-raised p-md shadow-raised', motion.reveal].join(' ')}> <Text size="sm" tone="subtle"> {dirty ? t.unsaved : ''} </Text> <Inline gap="sm"> <Button variant="tertiary" size="sm" onClick={() => { setGeneral(saved); setSaving('idle'); }} > {t.cancel} </Button> <Button type="submit" size="sm" loading={saving === 'saving'} success={saving === 'saved'} successLabel=""> {t.save} </Button> </Inline> </div> ) : null} </Stack> </form> </TabPanel>
<TabPanel value="notifications"> <Stack gap="lg" className="pt-xl"> {t.notifications.map((label, index) => ( <Switch key={label} label={label} checked={toggles[index] ?? false} loading={pendingToggle === index} onCheckedChange={(checked) => toggle(index, checked)} /> ))} </Stack> </TabPanel>
<TabPanel value="security"> <Stack gap="xl" className="pt-xl"> <TextInput label={t.lastSignIn} readOnly value={t.lastSignInValue} /> <Inline justify="between" align="center" gap="md" wrap> <Stack gap="xxs"> <Text weight="medium">{t.recovery}</Text> <Text tone="subtle" numeric> {recovery} </Text> </Stack> <Dialog title={t.recoveryTitle} description={t.recoveryHint} size="sm" open={editing} onOpenChange={openRecovery} trigger={ <Button variant="secondary" size="sm" aria-label={`${t.recoveryEdit}: ${t.recovery}`}> {t.recoveryEdit} </Button> } footer={ <> <Dialog.Close> <Button variant="secondary">{t.cancel}</Button> </Dialog.Close> <Button type="submit" form={`recovery-${lang}`}> {t.save} </Button> </> } > <form id={`recovery-${lang}`} noValidate onSubmit={saveRecovery}> <TextInput label={t.recoveryLabel} type="email" autoComplete="email" required value={draft} onValueChange={(value) => { setDraft(value); setDraftError(false); }} {...(draftError ? { error: t.recoveryInvalid } : {})} /> </form> </Dialog> </Inline> <Switch label={t.twoStep} description={t.twoStepHint} checked={twoStep} onCheckedChange={setTwoStep} /> <Card variant="outlined" padding="lg" className="border-status-danger-border"> <section aria-labelledby={`danger-${lang}`}> <Stack gap="md"> <Heading level={2} size="sm" id={`danger-${lang}`}> {t.danger} </Heading> <Text tone="subtle">{t.deleteText}</Text> <div> <AlertDialog tone="danger" title={t.deleteTitle} description={t.deleteDescription} confirmLabel={t.confirmDelete} cancelLabel={t.cancel} onConfirm={confirmDelete} onOpenChange={resetConfirm} trigger={ <Button variant="tertiary" tone="danger"> {t.deleteAction} </Button> } > <TextInput label={t.confirmLabel} description={WORKSPACE} autoComplete="off" value={typed} onValueChange={(value) => { setTyped(value); setMismatch(false); }} {...(mismatch ? { error: t.confirmMismatch } : {})} /> </AlertDialog> </div> </Stack> </section> </Card> </Stack> </TabPanel>
<TabPanel value="appearance"> <Stack gap="xl" className="pt-xl"> <RadioGroup label={t.theme} defaultValue="system" orientation="horizontal"> {(['light', 'dark', 'system'] as const).map((value) => ( <Radio key={value} value={value} label={t.themes[value]} /> ))} </RadioGroup> <Switch label={t.reduceMotion} description={t.reduceMotionHint} /> </Stack> </TabPanel> </Tabs> );}Structure
Section titled “Structure”- AppShell template (
width="md") and theh1“Settings”. - Tabs: General, Notifications, Security, Appearance.
- General: a TextInput, two Selects, a Textarea and a sticky save bar that shows only with changes.
- Notifications: three Switches, each saving itself.
- Security: a read-only “Last sign-in”, the recovery email with a “Change” button that opens a Dialog, two-step verification and a danger zone card. “Delete workspace” opens an AlertDialog that asks for the workspace name.
- Appearance: a theme RadioGroup and a “Reduce motion” switch.
- Results (saved, updated, deleted) appear as Toasts from the template’s
Toaster.
Accessibility
Section titled “Accessibility”- The tab list is named “Settings sections”; arrow keys move between tabs.
- Save, update and delete results are toasts in the polite “Notifications” region (F6 reaches it); the button’s own success announcement is off, so nothing is read twice.
- “Last sign-in” is read-only: it takes focus and can be copied but not changed (Disabled and read-only).
- The delete AlertDialog has role
alertdialog, ignores outside presses and puts focus on Cancel first. A wrong name keeps it open (theonConfirmpromise rejects) and shows an error under the field; while the deletion runs, the confirm button is busy and nothing closes the dialog. Focus then returns to “Delete workspace”. - The “Change” button is named “Change: Recovery email”; the dialog focuses the email field, and Escape or Cancel closes it without saving.
Components used
Section titled “Components used”Tabs, TextInput, Select, Textarea, Switch, RadioGroup, Card, Button, Dialog, AlertDialog, Toast. Patterns: Saving, Forms and validation, Confirming irreversible actions, Disabled and read-only.