Standardize Settings layout and save feedback (#2122)

* Group settings navigation menu

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Revert "Group settings navigation menu"

This reverts commit 5983a4e82074b8dab1084af1cadd803ba28ea65d.

* Standardize settings layout feedback

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Fix settings save status timer typing

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Retain settings save status

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Report color mode save state

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Match Appearance settings to two-column layout

Rebuild Appearance into Color mode & Theme, Localization, and Density & type sections with responsive two-column grids, consistent section headers, page description, and green save status.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Migrate settings pages to SettingsPageLayout and SettingsSection

Replace ScrollableOverlay/max-w-3xl shells with the shared settings
layout primitives across entity and static settings pages, normalize
section headers, and add settings.page.behavior.description locales.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Unify remaining settings pages on shared section chrome

Wire Appearance, Projects, and Remote Instances through SettingsSection/SettingsPageLayout so every settings surface shares the same header, divider, and page shell treatment.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Align settings UI with OpenChamber design system kit

Apply kit geometry and control specs: 840px content width, 32/48 padding, fixed 260/280 sidebars, radius/spacing tokens, settings select height, stepper dimensions, and shared field/link typography.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Migrate OpenChamberVisualSettings to shared settings primitives

Replace ad-hoc radio/checkbox/chip/field layouts with SettingsSection
shared chrome for Appearance, Density, Navigation, Chat/behavior, and
Privacy while preserving handlers and data-settings-item anchors.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Migrate settings pages to shared field/checkbox/radio primitives

Unify Defaults, Notifications, Behavior, Git, Session Retention, Passkeys,
OpenCode CLI, Commands, About, Keyboard Shortcuts, and Desktop Network on
SettingsFieldRow / SettingsCheckboxRow / SettingsRadioGroup / SettingsChipGroup
for consistent grid, spacing, and DRY layout. Also remove the GitPage double
SettingsSection wrap around GitHubSettings.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Unify remaining settings pages onto shared field primitives

Migrate Agents, Snippets, Skills, Usage, MCP identity rows and selects to
SettingsFieldRow/CheckboxRow/ChipGroup and SETTINGS_SELECT_*; align page
titles; light-touch Voice/Tunnel/Providers/Plugins without rewriting
complex OAuth, permissions, or tunnel flows.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Polish settings section dividers and transport helper text

Slightly stronger section borders for clearer group separation, and keep
message-stream transport description under the chip control.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Fix settings grid alignment, control heights, and Chat section titles

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Tighten settings grid: flat Chat 2x2 and full-width stacked selects

Message options use a flat two-column grid so row headers share a baseline.
Stacked selects fill their column; field-row selects keep a fixed sm:w-56 width.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Align mixed settings controls to shared FieldRow/CheckboxRow grid

Separate FieldRows from CheckboxRows with SettingsInset, move enum
radios into ControlGroups, and convert misplaced StackedFields to
full-width FieldRows so left edges no longer clash.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Translate Behavior response-style preset labels for es and pl

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Group settings nav into categories and improve icons/order

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Fix settings layout: fewer hrs, shared control widths, chat transport section

Remove SettingsInset top borders, align App install/Density controls to full cluster width, give Message Stream Transport its own Chat section, and fold Sessions Small Model into the first section to cut extra dividers.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Align Voice and MCP settings labels with shared heading classes

Swap form field labels to SETTINGS_FIELD_LABEL_CLASS and use
SettingsGroupTitle for MCP control-group headings (manual auth fallback,
request headers).

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Differentiate settings heading levels by context

Page titles are larger and quieter than section titles; group and field
labels use dedicated shared classes so hierarchy is consistent across
settings surfaces without ad-hoc typography mixes.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Use shared settings title classes in SettingsView home

Wire home and unavailable headings through the shared L1/L2 class
constants so they stay aligned with SettingsPageLayout.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Keep settings number steppers from stretching +/- buttons

Density & type NumberInputs no longer flex-grow across the row, and
NumberInput locks minus/plus to fixed width so the plus side cannot
inflate when the control is placed in a full-width cluster.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Place spacing and input-bar offset on their own row

Density & type now lays out as font families, then font sizes, then
Spacing Density / Input Bar Offset on the row below.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Regroup settings nav and improve Voice layout

Drop Advanced/Usage/Git nav categories: Voice and About under
Interface, Usage under OpenCode, Git under Workspace. Voice provider
chips and STT model cards use shared settings primitives with roomier
spacing and a two-column model grid.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Space out chat feature groups in visual settings

* Polish settings: save-state wiring, container-query layouts, unified dropdown triggers

- Wire shared save indicator into Behavior page saves and git identity CRUD
- Convert settings layout primitives and page rows from viewport to container
  queries so narrow panes stack instead of clipping
- Unify custom dropdown triggers (model/agent/provider pickers) and remaining
  page selects on the settings control size
- Bump global radius scale by 1px; align variant input with select size
- Migrate stray raw controls (MCP OAuth checkbox, worktree remove button,
  git identity dialog rows, skills dialog labels) onto shared primitives
- Make settings nav items span full sidebar width; trim nav to 240px

* Add General settings page, regroup nav, cap control widths, promote chat feature headers

* Mobile settings nav: plain background and touch-sized rows

* Hide secondary settings descriptions behind clickable info hints

* Move quota credentials to Usage, navigation settings to General, rename External Tunnel

* Quiet settings save indicator: silent success, delayed spinner, visible errors

* Rewrite settings-ui-patterns skill around shared primitives and refactored conventions

* Remove settings starter page; open last visited page, defaulting to General

* Settings polish: spacing, control sizes, trigger widths, readable model names, device dates

* Centralize dropdown trigger chrome, settings nav polish, mobile-only input bar offset

* Fix global line-height regression, auto-hide first-section divider, shortcut row spacing

* Global line-height 1.45, align scheduled tasks header button with select

* Hide editor toolbar and About in VS Code, animate chat render preview outside desktop dialog

* Rebuild agent tool permissions on source-of-truth model

Edit the agent's own permission map verbatim (inherit vs explicit actions, pattern rules only for pattern-capable keys), save permission-only, drop the server-side non-wildcard re-merge that resurrected deleted rules, and surface session-granted rules as read-only.

* Agents model parameters polish: row spacing, variant dropdown, unified widths, dash for unset numbers

---------

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Serhii Dziupin
2026-07-18 00:11:05 +03:00
committed by GitHub
co-authored by Serhii Dziupin Bohdan Triapitsyn
parent 1b402fcb67
commit 17f1b24709
103 changed files with 6406 additions and 6110 deletions
@@ -0,0 +1,70 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
interface SettingsInfoHintProps {
children: React.ReactNode;
className?: string;
/** Tooltip panel width cap. @default 'max-w-sm' */
contentClassName?: string;
}
/**
* Info icon revealing helper text on hover or click (click covers touch
* devices, where hover tooltips never open). Clicking outside closes it.
* Settings pages hide secondary descriptions behind this to keep the
* default view quiet.
*/
export const SettingsInfoHint: React.FC<SettingsInfoHintProps> = ({
children,
className,
contentClassName,
}) => {
const { t } = useI18n();
const [open, setOpen] = React.useState(false);
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
React.useEffect(() => {
if (!open) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
const trigger = triggerRef.current;
if (trigger && event.target instanceof Node && trigger.contains(event.target)) {
return;
}
setOpen(false);
};
document.addEventListener('pointerdown', handlePointerDown, true);
return () => document.removeEventListener('pointerdown', handlePointerDown, true);
}, [open]);
return (
<Tooltip open={open} onOpenChange={setOpen}>
<TooltipTrigger asChild>
<button
ref={triggerRef}
type="button"
aria-label={t('settings.common.infoAria')}
aria-expanded={open}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setOpen((current) => !current);
}}
className={cn(
'inline-flex h-5 w-5 shrink-0 cursor-help items-center justify-center rounded text-muted-foreground/60 hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
className,
)}
>
<Icon name="information" className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent sideOffset={8} className={cn('max-w-sm', contentClassName)}>
{children}
</TooltipContent>
</Tooltip>
);
};
@@ -1,10 +1,29 @@
import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { getSettingsSaveState, subscribeToSettingsSaveState } from '@/lib/persistence';
import { cn } from '@/lib/utils';
import {
SETTINGS_DESCRIPTION_CLASS,
SETTINGS_PAGE_TITLE_CLASS,
} from '@/components/sections/shared/SettingsSection';
interface SettingsPageLayoutProps {
/** Page content */
children: React.ReactNode;
/** Optional page title shown above settings content. */
title?: React.ReactNode;
/** Optional content rendered before a string/number page title. */
titleLeading?: React.ReactNode;
/** Optional content rendered after a string/number page title. */
titleAccessory?: React.ReactNode;
/** Optional supporting description under the page title. */
description?: React.ReactNode;
/** Optional content rendered at the end of the header row (before save status). */
headerEnd?: React.ReactNode;
/** Show persistence feedback for instant-save settings. */
showSaveStatus?: boolean;
/** Additional className for the content container */
className?: string;
/** Additional className for the outer ScrollableOverlay */
@@ -13,36 +32,120 @@ interface SettingsPageLayoutProps {
/**
* Standard layout wrapper for settings page content.
* Provides scrolling and centered max-width container.
*
* @example
* <SettingsPageLayout>
* <SettingsSection title="General">
* <SomeSettingsForm />
* </SettingsSection>
* <SettingsSection title="Advanced" divider>
* <OtherSettingsForm />
* </SettingsSection>
* </SettingsPageLayout>
* UI Kit: max-width 840px, padding 32px vertical / 48px horizontal.
*/
export const SettingsPageLayout: React.FC<SettingsPageLayoutProps> = ({
children,
className,
outerClassName,
title,
titleLeading,
titleAccessory,
description,
headerEnd,
showSaveStatus = false,
}) => {
const hasHeader = title != null || description != null || headerEnd != null || showSaveStatus;
const isPlainTitle = typeof title === 'string' || typeof title === 'number';
const hasTitleChrome = titleLeading != null || titleAccessory != null;
return (
<ScrollableOverlay
outerClassName={cn('h-full', outerClassName)}
className="w-full"
className="w-full @container"
>
<div
className={cn(
'mx-auto max-w-3xl space-y-6 p-3 sm:p-6 sm:pt-8',
'mx-auto max-w-[840px] space-y-0 px-6 py-6 @3xl:px-12 @3xl:py-8',
// The first visible section never needs the top divider, no matter
// which platform-conditional sections above it rendered null.
'[&>section:first-of-type]:border-t-0 [&>section:first-of-type]:pt-0',
className
)}
>
{hasHeader && (
<div className="mb-2 flex items-start justify-between gap-4 pb-6">
<div className="min-w-0 space-y-1">
{title != null ? (
isPlainTitle ? (
hasTitleChrome ? (
<div className="flex min-w-0 items-center gap-2">
{titleLeading}
<h1 className={cn(SETTINGS_PAGE_TITLE_CLASS, 'min-w-0 truncate')}>{title}</h1>
{titleAccessory}
</div>
) : (
<h1 className={SETTINGS_PAGE_TITLE_CLASS}>{title}</h1>
)
) : (
title
)
) : null}
{description != null ? (
typeof description === 'string' || typeof description === 'number' ? (
<p className={SETTINGS_DESCRIPTION_CLASS}>{description}</p>
) : (
description
)
) : null}
</div>
<div className="flex shrink-0 items-center gap-3">
{headerEnd}
{showSaveStatus && <SettingsSaveStatus />}
</div>
</div>
)}
{children}
</div>
</ScrollableOverlay>
);
};
// Only saves slower than this surface a "Saving…" spinner — local writes
// finish instantly and stay silent; remote/mobile connections get feedback.
const SAVE_SPINNER_DELAY_MS = 500;
const SettingsSaveStatus: React.FC = () => {
const { t } = useI18n();
const status = React.useSyncExternalStore(
subscribeToSettingsSaveState,
getSettingsSaveState,
getSettingsSaveState,
);
const [showSaving, setShowSaving] = React.useState(false);
React.useEffect(() => {
if (status !== 'saving') {
setShowSaving(false);
return;
}
const timer = setTimeout(() => setShowSaving(true), SAVE_SPINNER_DELAY_MS);
return () => clearTimeout(timer);
}, [status]);
if (status === 'error') {
return (
<div
aria-live="assertive"
className="flex shrink-0 items-center gap-1.5 typography-meta text-[var(--status-error)]"
>
<Icon name="error-warning" className="size-3.5" />
<span>{t('settings.common.status.saveFailed')}</span>
</div>
);
}
if (status !== 'saving' || !showSaving) {
return null;
}
return (
<div
aria-live="polite"
className="flex shrink-0 items-center gap-1.5 typography-meta text-muted-foreground"
>
<Icon name="loader-4" className="size-3.5 animate-spin" />
<span>{t('settings.common.actions.saving')}</span>
</div>
);
};
@@ -0,0 +1,558 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Radio } from '@/components/ui/radio';
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
import { cn } from '@/lib/utils';
import { SettingsInfoHint } from './SettingsInfoHint';
/** Settings select trigger: full column width in stacked cells; capped in field rows via parent. */
export const SETTINGS_SELECT_TRIGGER_CLASS = 'w-full min-w-40 max-w-48';
export const SETTINGS_SELECT_SIZE = 'settings' as const;
/** Fixed-width select used inside full-width SettingsFieldRow control columns. */
export const SETTINGS_SELECT_ROW_TRIGGER_CLASS = 'w-full min-w-40 max-w-48';
/** Compact reset / icon action next to a settings control (matches h-8 controls). */
export const SETTINGS_ICON_BUTTON_CLASS =
'h-8 w-8 px-0 text-muted-foreground hover:text-foreground';
/** Custom dropdown triggers (ModelSelector / AgentSelector) in settings field rows. */
// eslint-disable-next-line react-refresh/only-export-components
export const SETTINGS_CUSTOM_TRIGGER_CLASS = cn(
dropdownTriggerVariants(),
'w-full min-w-40 max-w-48',
);
/** Shared width for stacked control clusters (select/input + reset). */
export const SETTINGS_CONTROL_CLUSTER_CLASS = 'w-full max-w-[24rem]';
/** Fill a control cluster the same way as a full-width select. */
export const SETTINGS_CLUSTER_CONTROL_CLASS = 'min-w-0 flex-1';
/**
* Row wrapping a NumberInput + unit label + reset.
* Keep the stepper intrinsic — never flex-grow it or +/- buttons stretch unevenly.
*/
export const SETTINGS_NUMBER_STEPPER_ROW_CLASS = 'flex w-full min-w-0 items-center gap-2';
/** Unit suffix next to a settings number stepper (%, px, …). */
export const SETTINGS_NUMBER_UNIT_CLASS =
'typography-meta shrink-0 text-muted-foreground tabular-nums';
/** Vertical stack spacing for fields inside a column. */
export const SETTINGS_FIELDS_STACK_CLASS = 'space-y-4';
/** Compact checkbox / radio list stack. */
export const SETTINGS_OPTION_STACK_CLASS = 'space-y-1.5';
/**
* Settings heading classes by context (size + default color).
* Prefer these over ad-hoc typography-* + color combinations.
*/
/** L1 — page / detail-pane title (larger, quieter than section titles). */
export const SETTINGS_PAGE_TITLE_CLASS =
'typography-settings-page-title text-muted-foreground';
/** L2 — section title inside a settings page. */
export const SETTINGS_SECTION_TITLE_CLASS =
'typography-settings-section-title text-foreground';
/** Split-pane sidebar panel title — same level as section titles. */
export const SETTINGS_PANEL_TITLE_CLASS = SETTINGS_SECTION_TITLE_CLASS;
/** L3 — control-group heading inside a section. */
export const SETTINGS_GROUP_TITLE_CLASS =
'typography-settings-group-title text-foreground';
/** L4 — field / control labels. */
export const SETTINGS_FIELD_LABEL_CLASS =
'typography-settings-field-label text-foreground';
/** Supporting copy under page or section titles. */
export const SETTINGS_DESCRIPTION_CLASS =
'typography-settings-description text-muted-foreground';
/** Supporting copy under group titles and fields. */
export const SETTINGS_HELPER_CLASS = 'typography-meta text-muted-foreground';
/** Callout / alert headline inside a section (not a control-group title). */
export const SETTINGS_CALLOUT_TITLE_CLASS = 'typography-meta font-medium text-foreground';
/** Brand / product name under a logo — quieter than L1 page title. */
export const SETTINGS_BRAND_TITLE_CLASS =
'typography-settings-section-title text-foreground';
interface SettingsSectionProps {
/** Section title. Strings render as the shared h2 style. */
title?: React.ReactNode;
/** Optional supporting text under the title. */
description?: React.ReactNode;
/** Optional icon/badge next to the title. */
titleAccessory?: React.ReactNode;
/** Helper text hidden behind an info icon next to the title. */
info?: React.ReactNode;
/** Optional action aligned to the right of the header. */
headerAction?: React.ReactNode;
children: React.ReactNode;
/**
* Show a top border divider.
* Use `false` for the first section under the page header.
* @default true
*/
divider?: boolean;
className?: string;
contentClassName?: string;
settingsItem?: string;
}
/**
* Shared settings section chrome: single-style header + optional divider.
*/
export const SettingsSection: React.FC<SettingsSectionProps> = ({
title,
description,
titleAccessory,
info,
headerAction,
children,
divider = true,
className,
contentClassName,
settingsItem,
}) => {
const hasHeader = title != null || description != null || headerAction != null || info != null;
return (
<section
data-settings-item={settingsItem}
className={cn(
'space-y-5',
divider ? 'border-t border-border/60 py-8' : 'pb-8',
className,
)}
>
{hasHeader ? (
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
{title != null ? (
<div className="flex items-center gap-2">
{typeof title === 'string' || typeof title === 'number' ? (
<h2 className={SETTINGS_SECTION_TITLE_CLASS}>{title}</h2>
) : (
title
)}
{titleAccessory}
{info != null ? <SettingsInfoHint>{info}</SettingsInfoHint> : null}
</div>
) : null}
{description != null ? (
<div className={SETTINGS_DESCRIPTION_CLASS}>{description}</div>
) : null}
</div>
{headerAction ? <div className="shrink-0">{headerAction}</div> : null}
</div>
) : null}
<div className={cn(contentClassName)}>{children}</div>
</section>
);
};
interface SettingsTwoColumnProps {
children: React.ReactNode;
className?: string;
}
/** Responsive two-column settings grid used when space allows. */
export const SettingsTwoColumn: React.FC<SettingsTwoColumnProps> = ({
children,
className,
}) => {
return (
<div className={cn('grid grid-cols-1 gap-6 @3xl:grid-cols-2 @3xl:gap-10', className)}>
{children}
</div>
);
};
interface SettingsGroupTitleProps {
children: React.ReactNode;
className?: string;
as?: 'h2' | 'h3' | 'div';
}
/** In-section control-group heading (quieter than SettingsSection title). */
export const SettingsGroupTitle: React.FC<SettingsGroupTitleProps> = ({
children,
className,
as: Tag = 'h3',
}) => {
return (
<Tag className={cn(SETTINGS_GROUP_TITLE_CLASS, className)}>
{children}
</Tag>
);
};
interface SettingsControlGroupProps {
title?: React.ReactNode;
description?: React.ReactNode;
/** Helper text hidden behind an info icon next to the group title. */
info?: React.ReactNode;
children: React.ReactNode;
className?: string;
contentClassName?: string;
settingsItem?: string;
}
/** Labeled control cluster inside a section (radios, chips, stacked fields). */
export const SettingsControlGroup: React.FC<SettingsControlGroupProps> = ({
title,
description,
info,
children,
className,
contentClassName,
settingsItem,
}) => {
return (
<div data-settings-item={settingsItem} className={cn('space-y-2', className)}>
{title != null || description != null || info != null ? (
<div className="space-y-0.5">
{title != null ? (
<div className="flex items-center gap-1.5">
<SettingsGroupTitle>{title}</SettingsGroupTitle>
{info != null ? <SettingsInfoHint>{info}</SettingsInfoHint> : null}
</div>
) : null}
{description != null ? (
<p className={SETTINGS_HELPER_CLASS}>{description}</p>
) : null}
</div>
) : null}
<div className={cn(contentClassName)}>{children}</div>
</div>
);
};
interface SettingsStackedFieldProps {
label: React.ReactNode;
description?: React.ReactNode;
/** Helper text hidden behind an info icon next to the label. */
info?: React.ReactNode;
/** Where helper text sits relative to the control. @default 'before' */
descriptionPlacement?: 'before' | 'after';
children: React.ReactNode;
settingsItem?: string;
className?: string;
controlClassName?: string;
}
/**
* Label (+ optional description) above a control — for two-column cells.
* Prefer this over SettingsFieldRow inside SettingsTwoColumn (FieldRow overflows half-width columns).
*/
export const SettingsStackedField: React.FC<SettingsStackedFieldProps> = ({
label,
description,
info,
descriptionPlacement = 'before',
children,
settingsItem,
className,
controlClassName,
}) => {
const descriptionNode =
description != null ? (
<p className={SETTINGS_HELPER_CLASS}>{description}</p>
) : null;
return (
<div data-settings-item={settingsItem} className={cn('space-y-2', className)}>
<div className="space-y-0.5">
<div className="flex items-center gap-1.5">
<div className={SETTINGS_FIELD_LABEL_CLASS}>{label}</div>
{info != null ? <SettingsInfoHint>{info}</SettingsInfoHint> : null}
</div>
{descriptionPlacement === 'before' ? descriptionNode : null}
</div>
<div className={cn('flex min-w-0 max-w-[24rem] items-center gap-2', controlClassName)}>{children}</div>
{descriptionPlacement === 'after' ? descriptionNode : null}
</div>
);
};
interface SettingsFieldRowProps {
label: React.ReactNode;
description?: React.ReactNode;
/** Helper text hidden behind an info icon next to the label. */
info?: React.ReactNode;
children: React.ReactNode;
settingsItem?: string;
className?: string;
controlClassName?: string;
/** Align control to the trailing edge on desktop. @default true */
alignEnd?: boolean;
}
/**
* Side-by-side form row: fixed label column + control cluster.
* Use inside full-width sections or single columns.
*/
export const SettingsFieldRow: React.FC<SettingsFieldRowProps> = ({
label,
description,
info,
children,
settingsItem,
className,
controlClassName,
alignEnd = true,
}) => {
return (
<div
data-settings-item={settingsItem}
className={cn(
'flex flex-col gap-2 py-0.5 @xl:flex-row @xl:items-center @xl:gap-8',
className,
)}
>
<div className="min-w-0 @xl:w-56 @xl:shrink-0">
<div className="flex items-center gap-1.5">
<div className={SETTINGS_FIELD_LABEL_CLASS}>{label}</div>
{info != null ? <SettingsInfoHint>{info}</SettingsInfoHint> : null}
</div>
{description != null ? (
<p className={cn(SETTINGS_HELPER_CLASS, 'mt-0.5')}>{description}</p>
) : null}
</div>
<div
className={cn(
'flex min-w-0 flex-1 items-center gap-2 @xl:w-fit @xl:flex-none',
alignEnd && '@xl:justify-end',
controlClassName,
)}
>
{children}
</div>
</div>
);
};
interface SettingsInsetProps {
children: React.ReactNode;
className?: string;
settingsItem?: string;
}
/** Optional inset block under a section (spacing only; section dividers own borders). */
export const SettingsInset: React.FC<SettingsInsetProps> = ({
children,
className,
settingsItem,
}) => {
return (
<div
data-settings-item={settingsItem}
className={cn('pt-4', className)}
>
{children}
</div>
);
};
interface SettingsCheckboxRowProps {
checked: boolean;
onChange: (checked: boolean) => void;
label: React.ReactNode;
description?: React.ReactNode;
disabled?: boolean;
ariaLabel?: string;
settingsItem?: string;
className?: string;
labelAccessory?: React.ReactNode;
/** Helper text hidden behind an info icon next to the label. */
info?: React.ReactNode;
}
/** Shared checkbox setting row with keyboard support. */
export const SettingsCheckboxRow: React.FC<SettingsCheckboxRowProps> = ({
checked,
onChange,
label,
description,
disabled = false,
ariaLabel,
settingsItem,
className,
labelAccessory,
info,
}) => {
const toggle = () => {
if (!disabled) onChange(!checked);
};
const hasDescription = description != null;
return (
<div
data-settings-item={settingsItem}
className={cn(
'group flex cursor-pointer gap-2 py-0.5',
hasDescription ? 'items-start' : 'items-center',
disabled && 'cursor-not-allowed opacity-60',
className,
)}
role="button"
tabIndex={disabled ? -1 : 0}
aria-pressed={checked}
aria-disabled={disabled || undefined}
onClick={toggle}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
toggle();
}
}}
>
<Checkbox
checked={checked}
onChange={onChange}
disabled={disabled}
ariaLabel={ariaLabel}
/>
<div className="flex min-w-0 flex-col">
<div className="flex min-w-0 items-center gap-1.5">
<span className={SETTINGS_FIELD_LABEL_CLASS}>{label}</span>
{labelAccessory}
{info != null ? <SettingsInfoHint>{info}</SettingsInfoHint> : null}
</div>
{hasDescription ? (
<span className={SETTINGS_HELPER_CLASS}>{description}</span>
) : null}
</div>
</div>
);
};
interface SettingsRadioOptionProps {
selected: boolean;
onSelect: () => void;
label: React.ReactNode;
description?: React.ReactNode;
ariaLabel?: string;
disabled?: boolean;
className?: string;
}
/** Single radio option row used inside SettingsRadioGroup. */
export const SettingsRadioOption: React.FC<SettingsRadioOptionProps> = ({
selected,
onSelect,
label,
description,
ariaLabel,
disabled = false,
className,
}) => {
return (
<div
className={cn(
'flex cursor-pointer items-start gap-2 py-0.5',
disabled && 'cursor-not-allowed opacity-60',
className,
)}
role="button"
tabIndex={disabled ? -1 : 0}
aria-pressed={selected}
aria-disabled={disabled || undefined}
onClick={() => {
if (!disabled) onSelect();
}}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
if (!disabled) onSelect();
}
}}
>
<Radio
checked={selected}
onChange={onSelect}
disabled={disabled}
ariaLabel={ariaLabel}
className={description != null ? 'mt-0.5' : undefined}
/>
<div className="flex min-w-0 flex-col">
<span
className={cn(
'typography-settings-field-label font-normal',
selected ? 'text-foreground' : 'text-foreground/50',
)}
>
{label}
</span>
{description != null ? (
<span className={SETTINGS_HELPER_CLASS}>{description}</span>
) : null}
</div>
</div>
);
};
interface SettingsRadioGroupProps {
'aria-label': string;
children: React.ReactNode;
className?: string;
}
/** Accessible radio group wrapper with compact vertical spacing. */
export const SettingsRadioGroup: React.FC<SettingsRadioGroupProps> = ({
'aria-label': ariaLabel,
children,
className,
}) => {
return (
<div role="radiogroup" aria-label={ariaLabel} className={cn(SETTINGS_OPTION_STACK_CLASS, className)}>
{children}
</div>
);
};
interface SettingsChipOption<T extends string> {
value: T;
label: React.ReactNode;
disabled?: boolean;
}
interface SettingsChipGroupProps<T extends string> {
value: T;
options: Array<SettingsChipOption<T>>;
onChange: (value: T) => void;
className?: string;
'aria-label'?: string;
}
/** Compact chip / segmented enum picker. */
export function SettingsChipGroup<T extends string>({
value,
options,
onChange,
className,
'aria-label': ariaLabel,
}: SettingsChipGroupProps<T>) {
return (
<div
role="group"
aria-label={ariaLabel}
className={cn('flex flex-wrap items-center gap-1', className)}
>
{options.map((option) => (
<Button
key={option.value}
type="button"
variant="chip"
size="xs"
disabled={option.disabled}
aria-pressed={value === option.value}
className="!font-normal"
onClick={() => onChange(option.value)}
>
{option.label}
</Button>
))}
</div>
);
}