Files
openchamber/packages/ui/src/components/sections/shared/SettingsPageLayout.tsx
T

158 lines
5.6 KiB
TypeScript

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 */
outerClassName?: string;
}
/**
* Standard layout wrapper for settings page content.
* 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 @container"
>
<div
className={cn(
'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 && (
// Wraps rather than squeezes. The action cluster never shrinks, so on
// a narrow pane it used to starve the title until the name was a
// single letter and an ellipsis; giving the title block a basis lets
// the actions drop to their own line instead.
<div className="mb-2 flex flex-wrap items-start justify-between gap-x-4 gap-y-3 pb-6">
<div className="min-w-0 flex-1 basis-64 space-y-1">
{title != null ? (
isPlainTitle ? (
hasTitleChrome ? (
<div className="flex min-w-0 items-center gap-2">
{titleLeading}
<h1 data-settings-page-heading tabIndex={-1} className={cn(SETTINGS_PAGE_TITLE_CLASS, 'min-w-0 truncate')}>{title}</h1>
{/* A status badge carries a fixed word; compressing it
wraps the text inside its own pill. */}
<span className="shrink-0">{titleAccessory}</span>
</div>
) : (
<h1 data-settings-page-heading tabIndex={-1} 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 flex-wrap items-center justify-end 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>
);
};