feat: explain technical terms next to their labels in Settings and the admin area

This commit is contained in:
Linus Rath
2026-10-29 18:10:29 +01:00
parent 724c45d6e3
commit 5e188998a7
18 changed files with 90 additions and 31 deletions
+1 -1
View File
@@ -262,7 +262,7 @@ export function AuthPage() {
<div className="space-y-10">
<SectionNav />
<SettingsSection id="oauth" title="OAuth / OpenID Connect">
<SettingsSection id="oauth" title="OAuth / OpenID Connect" term="oauth">
<SettingItem
label="Set up with Stalwart"
description="Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here. Requires your Stalwart account to have admin permissions."
+6 -5
View File
@@ -18,6 +18,7 @@ import {
resolveDefaultRelayUrl,
} from '@/lib/push-relays';
import { SettingsSection, SettingItem, ToggleSwitch, SectionNav } from '@/components/settings/settings-section';
import type { GlossaryId } from '@/components/ui/term';
import { Button } from '@/components/ui/button';
import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminSaveBar } from '@/components/admin/save-bar';
@@ -29,7 +30,7 @@ import { inputClass } from '@/components/admin/config-items';
// forward on policy load), so it is hidden from the admin UI.
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled', 'allMailViewEnabled'];
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string; term?: GlossaryId }>> = {
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
settingsExportEnabled: { label: 'Settings Export/Import', description: 'Allow users to export and import settings JSON' },
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
@@ -37,13 +38,13 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
calendarEnabled: { label: 'Calendar', description: 'Enable calendar features and views' },
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing', term: 'smime' },
externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
tabTitleSubjectEnabled: { label: 'Subject in Tab Title', description: 'Show the open message\'s subject in the browser tab title. Browsers also keep the title in history and screen shares show it; turn this off when mailboxes hold confidential correspondence, and the tab shows the folder instead.' },
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.', term: 'webdav' },
crossUnreadViewEnabled: { label: 'Unified Mailbox: Unread', description: 'Allow an "Unread" entry in the Unified Mailbox section that lists unread mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
crossAllViewEnabled: { label: 'Unified Mailbox: All Mail', description: 'Allow an "All mail" entry in the Unified Mailbox section that lists all mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
@@ -290,9 +291,9 @@ export function PolicyPage() {
.map(key => {
const meta = FEATURE_GATE_LABELS[key];
if (!meta) return null;
const { label, description } = meta;
const { label, description, term } = meta;
return (
<SettingItem key={key} label={label} description={description}>
<SettingItem key={key} label={label} description={description} term={term}>
<ToggleSwitch checked={!!policy.features[key]} onChange={() => toggleFeature(key)} />
</SettingItem>
);
+5 -4
View File
@@ -12,6 +12,7 @@ import { withEdit } from '@/lib/admin/edits';
import { useAdminFeedback } from '@/components/admin/feedback';
import { ConfigTextItem, ConfigToggleItem, ConfigSelectItem, ConfigNote } from '@/components/admin/config-items';
import { JmapServersSection } from './_jmap-servers-section';
import { Term } from '@/components/ui/term';
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
interface ConfigEntry {
@@ -118,11 +119,11 @@ export function SettingsPage() {
<SettingsSection id="general" title="General">
<ConfigTextItem label="Application name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigTextItem label="JMAP server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" wide />
<ConfigTextItem label="JMAP server URL" term="jmap" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" wide />
<ConfigToggleItem label="Allow custom JMAP endpoint" description="Show a JMAP server URL field on the login form, allowing users to connect to any JMAP server" configKey="allowCustomJmapEndpoint" value={currentValue('allowCustomJmapEndpoint') as boolean} source={config.allowCustomJmapEndpoint?.source} onChange={handleChange} onRevert={handleRevert} />
{!!currentValue('allowCustomJmapEndpoint') && (
<ConfigNote tone="warning">
<strong>CORS warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
<strong><Term id="cors">CORS</Term> warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
</ConfigNote>
)}
<ConfigToggleItem label="Stalwart features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
@@ -132,8 +133,8 @@ export function SettingsPage() {
<SettingsSection id="login" title="Login page">
<ConfigToggleItem label="Show version" description="Show the webmail version in the login page footer. Turn off to keep the exact version hidden from visitors who are not signed in." configKey="loginShowVersion" value={currentValue('loginShowVersion') as boolean} source={config.loginShowVersion?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigToggleItem label="Show 2FA code option" description="Show the &quot;I have a 2FA code&quot; toggle on the login form. Turn off when two-factor authentication is handled by an external identity provider. Accounts that require a code still get the field." configKey="loginShowTotp" value={currentValue('loginShowTotp') as boolean} source={config.loginShowTotp?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigToggleItem label="Show access token sign-in" description="Offer signing in with an access token instead of a password. For JMAP servers that authenticate API tokens as Bearer tokens, such as Fastmail, which accepts no passwords over JMAP." configKey="loginShowTokenLogin" value={currentValue('loginShowTokenLogin') as boolean} source={config.loginShowTokenLogin?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigToggleItem label="Show 2FA code option" term="two_factor" description="Show the &quot;I have a 2FA code&quot; toggle on the login form. Turn off when two-factor authentication is handled by an external identity provider. Accounts that require a code still get the field." configKey="loginShowTotp" value={currentValue('loginShowTotp') as boolean} source={config.loginShowTotp?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigToggleItem label="Show access token sign-in" term="access_token" description="Offer signing in with an access token instead of a password. For JMAP servers that authenticate API tokens as Bearer tokens, such as Fastmail, which accepts no passwords over JMAP." configKey="loginShowTokenLogin" value={currentValue('loginShowTokenLogin') as boolean} source={config.loginShowTokenLogin?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigToggleItem label="Show remember me" description="Let people choose whether to stay signed in. Off (the default) remembers every sign-in. Either way this needs SESSION_SECRET to be set." configKey="loginShowRememberMe" value={currentValue('loginShowRememberMe') as boolean} source={config.loginShowRememberMe?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigToggleItem label="Show heading" description="Show the application name below the login logo" configKey="loginShowHeading" value={currentValue('loginShowHeading') as boolean} source={config.loginShowHeading?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigToggleItem label="Show subtitle" description="Show the subtitle below the login heading" configKey="loginShowSubtitle" value={currentValue('loginShowSubtitle') as boolean} source={config.loginShowSubtitle?.source} onChange={handleChange} onRevert={handleRevert} />
+9 -6
View File
@@ -3,6 +3,7 @@
import { useId, type ReactNode } from 'react';
import { RotateCcw } from '@/components/icons';
import { SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
import type { GlossaryId } from '@/components/ui/term';
import { cn } from '@/lib/utils';
import { Badge } from './badge';
@@ -52,6 +53,8 @@ interface ConfigItemBase {
/** Rows that only matter in unusual setups, hidden behind the Advanced switch. */
advanced?: boolean;
disabled?: boolean;
/** A technical term in the label that gets an info button with its explanation. */
term?: GlossaryId;
}
interface ConfigTextItemProps extends ConfigItemBase {
@@ -63,10 +66,10 @@ interface ConfigTextItemProps extends ConfigItemBase {
wide?: boolean;
}
export function ConfigTextItem({ label, description, configKey, value, source, onChange, onRevert, placeholder, type = 'text', mono, wide, advanced, disabled }: ConfigTextItemProps) {
export function ConfigTextItem({ label, description, configKey, value, source, onChange, onRevert, placeholder, type = 'text', mono, wide, advanced, disabled, term }: ConfigTextItemProps) {
const id = useId();
return (
<SettingItem label={label} description={description} advanced={advanced}>
<SettingItem label={label} description={description} advanced={advanced} term={term}>
<div className="flex w-full items-center gap-1.5 sm:w-auto">
<SourceBadge source={source} />
<input
@@ -91,9 +94,9 @@ interface ConfigToggleItemProps extends ConfigItemBase {
value: boolean | undefined | null;
}
export function ConfigToggleItem({ label, description, configKey, value, source, onChange, onRevert, advanced, disabled }: ConfigToggleItemProps) {
export function ConfigToggleItem({ label, description, configKey, value, source, onChange, onRevert, advanced, disabled, term }: ConfigToggleItemProps) {
return (
<SettingItem label={label} description={description} advanced={advanced}>
<SettingItem label={label} description={description} advanced={advanced} term={term}>
<div className="flex items-center gap-1.5">
<SourceBadge source={source} />
<ToggleSwitch checked={!!value} disabled={disabled} onChange={(checked) => onChange(configKey, checked)} />
@@ -108,10 +111,10 @@ interface ConfigSelectItemProps extends ConfigItemBase {
options: ReadonlyArray<string | { value: string; label: string }>;
}
export function ConfigSelectItem({ label, description, configKey, value, source, options, onChange, onRevert, advanced, disabled }: ConfigSelectItemProps) {
export function ConfigSelectItem({ label, description, configKey, value, source, options, onChange, onRevert, advanced, disabled, term }: ConfigSelectItemProps) {
const normalized = options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
return (
<SettingItem label={label} description={description} advanced={advanced}>
<SettingItem label={label} description={description} advanced={advanced} term={term}>
<div className="flex items-center gap-1.5">
<SourceBadge source={source} />
<select
@@ -18,6 +18,7 @@ import { useConfig } from '@/hooks/use-config';
import { cn } from '@/lib/utils';
import { InheritNote, RestrictionRow } from '../org-page';
import type { OrgPolicyDraft } from '../use-org-policy-draft';
import { Term } from '@/components/ui/term';
const textareaClass =
'block w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs text-foreground ' +
@@ -302,7 +303,7 @@ export function SignatureSection({ draft }: { draft: OrgPolicyDraft }) {
/>
{sampleDisclaimer && <TemplatePreview html={sampleDisclaimer.html} text={sampleDisclaimer.text} testId="org-disclaimer-sample" />}
<p className="text-xs text-muted-foreground">
Bulwark adds the signature and the disclaimer in the browser. Mail sent from other apps over IMAP and SMTP gets neither.
Bulwark adds the signature and the disclaimer in the browser. Mail sent from other apps over <Term id="imap">IMAP</Term> and <Term id="smtp">SMTP</Term> gets neither.
</p>
</div>
</SettingsSection>
+2 -1
View File
@@ -3,6 +3,7 @@
import { useTranslations } from "next-intl";
import { Calendar as CalendarIcon, CheckSquare, Layers } from "@/components/icons";
import { cn } from "@/lib/utils";
import { TermInfo } from "@/components/ui/term";
import type { CalendarComponentType } from "@/lib/jmap/types";
/**
@@ -66,7 +67,7 @@ export function CalendarKindPicker({
</button>
))}
</div>
<p className="text-xs text-muted-foreground mt-1">{t("kind_hint")}</p>
<p className="text-xs text-muted-foreground mt-1">{t("kind_hint")} <TermInfo id="caldav" /></p>
</div>
);
}
@@ -15,7 +15,8 @@ describe('Email addresses section', () => {
render(<EmailAddressesSection />);
const row = screen.getByText('sub_addressing.label').closest('[data-search-label]') as HTMLElement;
expect(within(row).getByText('sub_addressing.description')).toBeTruthy();
expect(within(row).queryByRole('button')).toBeNull();
// Only the glossary info button that explains the term.
expect(within(row).queryAllByRole('button').map((b) => b.getAttribute('data-glossary-term'))).toEqual(['sub_addressing']);
fireEvent.click(screen.getByRole('button', { name: 'manage' }));
expect(screen.getByRole('dialog')).toBeTruthy();
@@ -16,6 +16,29 @@ describe('SettingItem controls', () => {
expect(toggle).toHaveAttribute('aria-checked', 'true');
});
it('puts a glossary info button beside the label without changing the toggle name', () => {
render(
<SettingItem label="App passwords" term="app_password">
<ToggleSwitch checked onChange={() => {}} />
</SettingItem>
);
// The mocked translator returns keys: the button is named by glossary.what_is.
const info = screen.getByRole('button', { name: 'what_is' });
expect(info).toHaveAttribute('data-glossary-term', 'app_password');
expect(info.closest('label')).toBeNull();
expect(screen.getByRole('switch', { name: 'App passwords' })).toBeInTheDocument();
});
it('adds a glossary info button to a section title', () => {
render(
<SettingsSection title="Delegates" term="delegation">
<p>rows</p>
</SettingsSection>
);
expect(screen.getByRole('heading', { name: /Delegates/ }).querySelector('[data-glossary-term="delegation"]')).not.toBeNull();
});
it('lets an explicit ariaLabel win over the row label', () => {
render(
<SettingItem label="Conversation view">
+2 -2
View File
@@ -183,7 +183,7 @@ export function AboutDataSettings() {
<div className="space-y-8">
<SettingsSection id="backup" title={t('sections.backup')} description={t('sections.backup_description')}>
{settingsSyncEnabled && (
<SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}>
<SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')} term="sync">
<ToggleSwitch checked={!settingsSyncDisabled} onChange={(checked) => updateSetting('settingsSyncDisabled', !checked)} />
</SettingItem>
)}
@@ -215,7 +215,7 @@ export function AboutDataSettings() {
</SettingsSection>
<SettingsSection id="fix" title={t('sections.fix')} description={t('sections.fix_description')}>
<SettingItem label={t('refresh_cache.label')} description={t('refresh_cache.description')}>
<SettingItem label={t('refresh_cache.label')} description={t('refresh_cache.description')} term="cache">
<Button variant="outline" size="sm" onClick={() => setShowRefreshConfirm(true)}>
{t('refresh_cache.button')}
</Button>
@@ -21,6 +21,7 @@ import { isPasswordSession } from '@/lib/auth/password-session';
import { useConfig } from '@/hooks/use-config';
import { totpIssuer } from '@/lib/totp-issuer';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { TermInfo } from '@/components/ui/term';
import { IconButton } from '@/components/ui/icon-button';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
@@ -332,7 +333,7 @@ function TotpSection() {
return (
<div className="space-y-3">
<SettingItem label={t('totp.label')} description={t('totp.description')}>
<SettingItem label={t('totp.label')} description={t('totp.description')} term="totp">
<div className="flex items-center gap-2">
<ToggleSwitch
checked={otpEnabled || !!setupUrl}
@@ -542,6 +543,7 @@ function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRem
<div className="flex items-center gap-2">
<Icon className="w-4 h-4 text-muted-foreground" />
<h4 data-search-label={tk('title')} className="text-sm font-medium text-foreground">{tk('title')}</h4>
{i18nNamespace === 'app_passwords' && <TermInfo id="app_password" />}
</div>
<Button variant="outline" size="sm" onClick={() => setShowAdd(!showAdd)}>
<Plus className="w-3 h-3 me-1" />
@@ -773,6 +775,7 @@ function PublicKeysSection() {
<div className="flex items-center gap-2">
<Lock className="w-4 h-4 text-muted-foreground" />
<h4 data-search-label={t('encryption.section_title')} className="text-sm font-medium text-foreground">{t('encryption.section_title')}</h4>
<TermInfo id="encryption_at_rest" />
</div>
<Button variant="outline" size="sm" onClick={() => setShowAdd(!showAdd)}>
<Plus className="w-3 h-3 me-1" />
@@ -794,7 +797,10 @@ function PublicKeysSection() {
/>
</div>
<div>
<label htmlFor={labelFor("field10")} className="text-xs text-muted-foreground mb-1 block">{tk('key_label')}</label>
<div className="mb-1 flex items-center gap-1">
<label htmlFor={labelFor("field10")} className="text-xs text-muted-foreground block">{tk('key_label')}</label>
<TermInfo id="public_key" />
</div>
<textarea
id={labelFor("field10")}
value={publicKey}
@@ -959,9 +965,12 @@ function EmailClientSection() {
<p className="text-xs text-muted-foreground">{t('email_client.description')}</p>
<div className="p-3 bg-muted/70 dark:bg-muted/40 rounded-md space-y-2">
<div>
<label htmlFor={labelFor('jmap-username')} className="text-xs text-muted-foreground mb-1 block">
{t('email_client.jmap_username_label')}
</label>
<div className="mb-1 flex items-center gap-1">
<label htmlFor={labelFor('jmap-username')} className="text-xs text-muted-foreground block">
{t('email_client.jmap_username_label')}
</label>
<TermInfo id="jmap" />
</div>
<div className="flex rounded-lg">
<input
id={labelFor('jmap-username')}
@@ -1585,6 +1594,7 @@ export function AccountSecuritySettings() {
<div className="flex items-center gap-2 mb-3">
<Shield className="w-4 h-4 text-muted-foreground" />
<h4 data-search-label={t('totp.section_title')} className="text-sm font-medium text-foreground">{t('totp.section_title')}</h4>
<TermInfo id="two_factor" />
</div>
<TotpSection />
</div>
+1 -1
View File
@@ -178,7 +178,7 @@ export function AccountSettings() {
</SettingItem>
{/* Server */}
<SettingItem label={t('server.label')} advanced>
<SettingItem label={t('server.label')} advanced term="jmap">
<span className="text-sm text-foreground truncate max-w-xs">
{serverUrl || tCommon('unknown')}
</span>
@@ -30,6 +30,7 @@ import { shareRecipientNames, useShareRecipientLabels } from '@/hooks/use-share-
import { calendarAccountKey, moveCalendar } from '@/lib/calendar-sort';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { TOUCH_HIT_AREA } from '@/components/ui/button';
import { TermInfo } from '@/components/ui/term';
import { useMediaQuery } from '@/hooks/use-media-query';
import { useConfig } from '@/hooks/use-config';
@@ -866,6 +867,7 @@ export function CalendarManagementSettings() {
<h4 className="text-sm font-medium text-foreground flex items-center gap-2">
<Globe className="w-4 h-4 text-muted-foreground" />
{tSub('section_title')}
<TermInfo id="icalendar" />
</h4>
{icalSubscriptions.map((sub) => {
return (
@@ -83,6 +83,7 @@ export function ContactsSettings() {
<SettingItem
label={tSettings("import_label")}
description={tSettings("import_description")}
term="vcard"
>
<Button variant="outline" size="sm" onClick={() => setShowImport(true)}>
<Upload className="w-4 h-4 me-2" />
@@ -155,7 +155,7 @@ export function DelegatesSettings() {
};
return (
<SettingsSection id="delegates" title={t("title")} description={t("description")}>
<SettingsSection id="delegates" title={t("title")} description={t("description")} term="delegation">
<div>
<Button onClick={() => setDialog({ mode: "add" })} disabled={!targets} data-testid="delegates-give-access">
<UserPlus className="me-2 h-4 w-4" />
+2
View File
@@ -5,6 +5,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { SettingsSection, ToggleSwitch } from "./settings-section";
import { Button } from "@/components/ui/button";
import { TermInfo } from "@/components/ui/term";
import { IconButton } from "@/components/ui/icon-button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
@@ -757,6 +758,7 @@ export function FilterSettings() {
<Code className="w-4 h-4 me-1" />
{t("raw_editor")}
</Button>
<TermInfo id="sieve" />
</div>
<div className="flex items-center gap-3">
@@ -38,6 +38,7 @@ export function EmailAddressesSection() {
<SettingItem
label={t('identities_count.label')}
description={t('identities_count.description')}
term="identity"
>
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">
@@ -58,6 +59,7 @@ export function EmailAddressesSection() {
<SettingItem
label={t('sub_addressing.label')}
description={t('sub_addressing.description')}
term="sub_addressing"
>
{null}
</SettingItem>
@@ -417,7 +417,7 @@ export function NotificationSettings() {
</SettingItem>
</SettingsSection>
<SettingsSection id="push" title={t('push.title')} description={t('push.description')}>
<SettingsSection id="push" title={t('push.title')} description={t('push.description')} term="push">
<SettingItem label={t('push.enable')} description={statusDescription}>
<div className="flex items-center gap-2">
{pushEnabled && (
+13 -2
View File
@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl';
import { OrgLockedNote } from './org-locked-note';
import { cn } from '@/lib/utils';
import { Button, TOUCH_HIT_AREA } from '@/components/ui/button';
import { TermInfo, type GlossaryId } from '@/components/ui/term';
import { useSettingsStore } from '@/stores/settings-store';
/**
@@ -189,9 +190,11 @@ interface SettingsSectionProps {
advanced?: boolean;
/** Anchor for section deep links and the in-page jump row. */
id?: string;
/** A technical term in the title that gets an info button with its explanation. */
term?: GlossaryId;
}
export function SettingsSection({ title, description, children, advanced, id }: SettingsSectionProps) {
export function SettingsSection({ title, description, children, advanced, id, term }: SettingsSectionProps) {
const visible = useAdvancedVisible(advanced);
if (!visible) return null;
return (
@@ -199,6 +202,7 @@ export function SettingsSection({ title, description, children, advanced, id }:
<div>
<h3 className="text-lg font-medium text-foreground flex items-center gap-2">
{title}
{term && <TermInfo id={term} />}
{advanced && <AdvancedBadge />}
</h3>
{description && (
@@ -219,6 +223,12 @@ interface SettingItemProps {
advanced?: boolean;
/** Indented under the row above it, for a sub-option of a toggle. */
nested?: boolean;
/**
* A technical term in the label that gets an info button with its
* explanation. The button sits beside the label, not inside it, so the
* row's control keeps the label alone as its name.
*/
term?: GlossaryId;
}
/** A field that has a name of its own: aria-label, aria-labelledby or a <label>. */
@@ -228,7 +238,7 @@ function hasOwnName(el: HTMLElement): boolean {
return !!el.id && !!el.ownerDocument.querySelector(`label[for="${CSS.escape(el.id)}"]`);
}
export function SettingItem({ label, description, children, locked, advanced, nested }: SettingItemProps) {
export function SettingItem({ label, description, children, locked, advanced, nested, term }: SettingItemProps) {
const labelId = useId();
const visible = useAdvancedVisible(advanced);
// A row with one text field, select or textarea and nothing naming it
@@ -256,6 +266,7 @@ export function SettingItem({ label, description, children, locked, advanced, ne
<div className="flex-1 min-w-0 sm:pe-4">
<div className="flex items-center gap-1.5">
<label id={labelId} className="text-sm font-medium text-foreground">{label}</label>
{term && <TermInfo id={term} />}
{advanced && <AdvancedBadge />}
</div>
{description && (