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:
committed by
GitHub
co-authored by
Serhii Dziupin
Bohdan Triapitsyn
parent
1b402fcb67
commit
17f1b24709
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user