このコミットが含まれているのは:
2026-07-05 20:00:58 +09:00
コミット 021d13a262
10個のファイルの変更647行の追加480行の削除
+372 -245
ファイルの表示
@@ -19,6 +19,7 @@ import { CATEGORY_NAMES, CATEGORIES } from '@/consts'
import { SITE_TITLE } from '@/config'
import { apiPut } from '@/lib/api'
import {
applyClientAppearance,
applyThemeSelection,
DEFAULT_USER_SETTINGS,
deriveUserThemeModeFromSlotSelection,
@@ -37,7 +38,7 @@ import {
upsertUserThemeSlot,
USER_THEME_SLOT_SELECTIONS,
} from '@/lib/settings'
import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts'
import { useKeyboardShortcutSettings, useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { cn, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
@@ -62,7 +63,7 @@ type Props = {
type UserFormField = 'name'
type SettingsFormField = 'theme'
type SettingsTab = 'account' | 'theme' | 'keyboard' | 'behavior'
type SettingsTab = 'account' | 'behavior' | 'theme' | 'keyboard'
type ThemeTokenKey = Exclude<keyof ThemeTokens, 'tagColours'>
type TabSpec = {
@@ -112,11 +113,15 @@ type ThemeSectionProps = {
onThemeSubmit: () => void
onThemeDiscard: () => void }
type SettingsTabDirtyState = {
dirty: boolean
discard: () => void | Promise<void> }
const tabs: TabSpec[] = [
{ id: 'account', label: 'アカウント' },
{ id: 'behavior', label: '動作' },
{ id: 'theme', label: 'テーマ' },
{ id: 'keyboard', label: 'キーボード' },
{ id: 'behavior', label: '動作' } ]
{ id: 'keyboard', label: 'キーボード' } ]
const sectionClassName =
'space-y-4 rounded-xl border border-border bg-background/80 p-4'
@@ -138,25 +143,15 @@ const themeTokenGroups: ThemeTokenGroupSpec[] = [
fields: [
{ key: 'background', label: '背景' },
{ key: 'foreground', label: '文字' },
{ key: 'primary', label: '主ボタン' },
{ key: 'primaryForeground', label: '主ボタン文字' },
{ key: 'primary', label: '主' },
{ key: 'accent', label: 'アクセント' },
{ key: 'accentForeground', label: 'アクセント文字' },
] },
{ title: 'パネル・カード',
{ title: 'パネル',
fields: [
{ key: 'card', label: 'カード背景' },
{ key: 'cardForeground', label: 'カード文字' },
{ key: 'popover', label: 'ポップアップ背景' },
{ key: 'popoverForeground', label: 'ポップアップ文字' },
{ key: 'muted', label: '薄い背景' },
{ key: 'mutedForeground', label: '薄い文字' },
] },
{ title: '入力欄・境界線',
fields: [
{ key: 'border', label: '境界線' },
{ key: 'input', label: '入力欄' },
{ key: 'ring', label: 'フォーカスリング' },
] },
{ title: 'リンク',
fields: [
@@ -164,15 +159,12 @@ const themeTokenGroups: ThemeTokenGroupSpec[] = [
] },
{ title: '上部ナビ',
fields: [
{ key: 'topNavBackground', label: '背景' },
{ key: 'topNavForeground', label: '文字' },
{ key: 'topNavBorder', label: '境界線' },
{ key: 'topNavLink', label: 'リンク' },
{ key: 'topNavLinkHover', label: 'リンク hover' },
{ key: 'topNavActiveBackground', label: '選択中背景' },
{ key: 'topNavActiveForeground', label: '選択中文字' },
{ key: 'topNavRootBackgroundMobile', label: '通常背景' },
{ key: 'topNavRootBackgroundDesktop', label: 'PC 背景' },
{ key: 'topNavHighlightBackground', label: '選択中背景' },
{ key: 'topNavSubmenuBackground', label: 'サブメニュー背景' },
{ key: 'topNavSubmenuForeground', label: 'サブメニュー文字' },
{ key: 'topNavMobileActiveBackground', label: 'スマホ選択中背景' },
{ key: 'topNavLink', label: 'リンク' },
] },
]
@@ -270,6 +262,10 @@ const themeRowBaseLabel = (
: themeLabelBySelection (selection)
const isTopNavThemeTokenKey = (key: ThemeTokenKey): boolean =>
key.startsWith ('topNav')
const formatHslComponent = (value: number): string =>
Number.isInteger (value) ? String (value) : value.toFixed (1).replace (/\.0$/, '')
@@ -367,6 +363,30 @@ const hexToHslToken = (hex: string): string => {
}
const themeTokenToHex = (
key: ThemeTokenKey,
value: string,
): string => {
if (isTopNavThemeTokenKey (key))
{
if (value.startsWith ('#'))
return value
const match = value.match (/^hsl\((.+)\)$/)
return match == null ? '#000000' : hslTokenToHex (match[1])
}
return hslTokenToHex (value)
}
const hexToThemeToken = (
key: ThemeTokenKey,
value: string,
): string =>
isTopNavThemeTokenKey (key) ? value : hexToHslToken (value)
const AccountSection: FC<AccountSectionProps> = (
{ name,
onInheritOpen,
@@ -443,183 +463,190 @@ const ThemeSection: FC<ThemeSectionProps> = (
onResetThemeSlot,
onThemeSubmit,
onThemeDiscard },
) => (
<section className={sectionClassName}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h2 className="text-xl font-bold"></h2>
<p className="text-sm text-muted-foreground">
: {selectedThemeLabel}
</p>
{editingThemeSlot && (
<p className="text-sm text-muted-foreground">
: {themeLabelBySelection (editingThemeSlot)}
</p>)}
{hasUnsavedChanges && (
<p className="text-sm text-amber-700 dark:text-amber-300">
</p>)}
) => {
const currentSelection =
themeSelections.find (item => item.id === draftActiveThemeSlot)
?? themeSelections[0]
const currentEditableSelection =
editingThemeSlot
?? (isUserThemeSlotSelection (draftActiveThemeSlot)
? draftActiveThemeSlot
: null)
const canEditTheme = currentEditableSelection != null
return (
<section className={sectionClassName}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h2 className="text-xl font-bold"></h2>
<p className="text-sm text-muted-foreground">
: {selectedThemeLabel}
</p>
{editingThemeSlot && (
<p className="text-sm text-muted-foreground">
: {themeLabelBySelection (editingThemeSlot)}
</p>)}
{hasUnsavedChanges && (
<p className="text-sm text-amber-700 dark:text-amber-300">
</p>)}
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
disabled={!(hasUnsavedChanges)}
onClick={onThemeDiscard}>
</Button>
<Button
type="button"
disabled={!(hasUnsavedChanges)}
onClick={onThemeSubmit}>
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
disabled={!(hasUnsavedChanges)}
onClick={onThemeDiscard}>
</Button>
<Button
type="button"
disabled={!(hasUnsavedChanges)}
onClick={onThemeSubmit}>
</Button>
</div>
</div>
<FieldError messages={settingsBaseErrors}/>
<FieldError messages={settingsFieldErrors.theme ?? []}/>
<div className="space-y-3">
{themeSelections.map (selection => {
const themeSlot =
selection.editable
? draftThemeSlots[selection.id as UserThemeSlotSelection]
: null
const isSelected = draftActiveThemeSlot === selection.id
const isEditing = editingThemeSlot === selection.id
return (
<div
key={selection.id}
className={cn (
'space-y-3 rounded-xl border border-border/70 bg-background/70 p-4',
isSelected && 'ring-1 ring-primary/30',
)}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-semibold">{selection.label}</p>
<p className="text-sm text-muted-foreground">
{themeRowBaseLabel (selection.id)}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant={isSelected ? 'default' : 'outline'}
onClick={() => onSelectThemeSlot (selection.id)}>
</Button>
{selection.editable && (
<>
<Button
type="button"
variant={isEditing ? 'default' : 'outline'}
onClick={() =>
onEditThemeSlot (selection.id as UserThemeSlotSelection)}>
</Button>
<Button
type="button"
variant="outline"
disabled={!(hasUnsavedChanges) || !(isEditing || isSelected)}
onClick={onThemeSubmit}>
</Button>
<Button
type="button"
variant="ghost"
onClick={() =>
onResetThemeSlot (selection.id as UserThemeSlotSelection)}>
</Button>
</>)}
</div>
</div>
{themeSlot && (
<div style={themePreviewStyle (themeSlot.tokens.tagColours)}>
<TagLink
tag={previewTags.general}
linkFlg={false}
truncateOnMobile
withWiki={false}/>
</div>)}
</div>)
})}
</div>
<div className="space-y-4">
{themeTokenGroups.map (group => (
<div
key={group.title}
className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
<h3 className="text-sm font-semibold">{group.title}</h3>
<div className="space-y-3">
{group.fields.map (field => (
<div
key={field.key}
className="flex flex-col gap-3 rounded-lg border border-border/70 px-3 py-3
sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">{field.label}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<input
type="color"
value={hslTokenToHex (selectedTheme.tokens[field.key])}
onPointerDown={onEnsureEditableTheme}
onFocus={onEnsureEditableTheme}
onChange={ev =>
onThemeTokenChange (field.key, hexToHslToken (ev.target.value))}
className="h-10 w-16 cursor-pointer rounded border border-border
bg-transparent p-1"/>
</div>
</div>))}
</div>
</div>))}
<FieldError messages={settingsBaseErrors}/>
<FieldError messages={settingsFieldErrors.theme ?? []}/>
<div className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="font-medium"></h3>
<div className="space-y-2">
<Label></Label>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<select
className={cn (inputClass (false), 'w-full min-w-0 sm:w-64')}
value={draftActiveThemeSlot}
onChange={ev =>
onSelectThemeSlot (ev.target.value as ThemeSlotSelection)}>
{themeSelections.map (selection => (
<option key={selection.id} value={selection.id}>
{selection.label}
</option>))}
</select>
<Button
type="button"
variant="outline"
onClick={() => {
if (isUserThemeSlotSelection (draftActiveThemeSlot))
{
onEditThemeSlot (draftActiveThemeSlot)
return
}
onEnsureEditableTheme ()
}}>
{canEditTheme ? 'この枠を編輯' : '編輯する枠を選ぶ'}
</Button>
{currentEditableSelection && (
<Button
type="button"
variant="ghost"
onClick={() => onResetThemeSlot (currentEditableSelection)}>
</Button>)}
</div>
</div>
<div className="space-y-3">
{CATEGORIES.map (category => (
<div
key={category}
className="flex flex-col gap-3 rounded-lg border border-border/70 px-3 py-3
sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-2">
<p className="text-sm font-medium">{CATEGORY_NAMES[category]}</p>
<div style={themePreviewStyle (selectedTheme.tokens.tagColours)}>
<TagLink
tag={previewTags[category]}
linkFlg={false}
truncateOnMobile
withWiki={false}/>
</div>
</div>
<div className="space-y-1">
<p className="text-sm font-semibold">{currentSelection.label}</p>
<p className="text-sm text-muted-foreground">
{themeRowBaseLabel (currentSelection.id)}
</p>
{canEditTheme
? (
<p className="text-sm text-muted-foreground">
</p>)
: (
<p className="text-sm text-muted-foreground">
</p>)}
</div>
<div className="flex flex-wrap items-center gap-3">
<input
type="color"
value={selectedTheme.tokens.tagColours[category]}
onPointerDown={onEnsureEditableTheme}
onFocus={onEnsureEditableTheme}
onChange={ev => onTagColourChange (category, ev.target.value)}
className="h-10 w-16 cursor-pointer rounded border border-border
bg-transparent p-1"/>
</div>
</div>))}
<div style={themePreviewStyle (selectedTheme.tokens.tagColours)}>
<TagLink
tag={previewTags.general}
linkFlg={false}
truncateOnMobile
withWiki={false}/>
</div>
</div>
</div>
</section>)
<div className="space-y-4">
{themeTokenGroups.map (group => (
<div
key={group.title}
className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
<h3 className="text-sm font-semibold">{group.title}</h3>
<div className="space-y-3">
{group.fields.map (field => (
<div
key={field.key}
className="flex flex-col gap-3 rounded-lg border border-border/70 px-3 py-3
sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">{field.label}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<input
type="color"
value={themeTokenToHex (field.key, selectedTheme.tokens[field.key])}
disabled={!(canEditTheme)}
onChange={ev =>
onThemeTokenChange (field.key, hexToThemeToken (field.key, ev.target.value))}
className="h-10 w-16 cursor-pointer rounded border border-border
bg-transparent p-1 disabled:cursor-not-allowed
disabled:opacity-50"/>
</div>
</div>))}
</div>
</div>))}
<div className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="font-medium"></h3>
</div>
</div>
<div className="space-y-3">
{CATEGORIES.map (category => (
<div
key={category}
className="flex flex-col gap-3 rounded-lg border border-border/70 px-3 py-3
sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-2">
<p className="text-sm font-medium">{CATEGORY_NAMES[category]}</p>
<div style={themePreviewStyle (selectedTheme.tokens.tagColours)}>
<TagLink
tag={previewTags[category]}
linkFlg={false}
truncateOnMobile
withWiki={false}/>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<input
type="color"
value={selectedTheme.tokens.tagColours[category]}
disabled={!(canEditTheme)}
onChange={ev => onTagColourChange (category, ev.target.value)}
className="h-10 w-16 cursor-pointer rounded border border-border
bg-transparent p-1 disabled:cursor-not-allowed
disabled:opacity-50"/>
</div>
</div>))}
</div>
</div>
</div>
</section>)
}
const SettingPage: FC<Props> = ({ user, setUser }) => {
@@ -642,6 +669,8 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
const [editingThemeSlot, setEditingThemeSlot] =
useState<UserThemeSlotSelection | null> (null)
const [dirtyStates, setDirtyStates] =
useState<Partial<Record<SettingsTab, SettingsTabDirtyState>>> ({ })
const [userCodeVsbl, setUserCodeVsbl] = useState (false)
const { conflictActionIds } = useKeyboardShortcutSettings ()
const { baseErrors: userBaseErrors,
@@ -676,7 +705,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
[draftActiveThemeSlot, draftThemeSlots, savedActiveThemeSlot, savedThemeSlots],
)
const setActiveTab = (tab: SettingsTab) => {
const applyActiveTab = (tab: SettingsTab) => {
const params = new URLSearchParams (location.search)
params.set ('tab', tab)
navigate (
@@ -684,12 +713,43 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
{ replace: true })
}
const updateTabDirtyState = (
tab: SettingsTab,
state: SettingsTabDirtyState,
) => {
setDirtyStates (current => {
const next = { ...current }
if (!(state.dirty))
{
delete next[tab]
return next
}
next[tab] = state
return next
})
}
const confirmDiscardChanges = async (
description: string,
cancelText: string,
confirmText: string,
): Promise<boolean> =>
dialogue.confirm ({
title: '未保存の変更があります',
description,
confirmText,
cancelText,
variant: 'danger',
})
const settingsTabErrors = useMemo<Record<SettingsTab, boolean>> (
() => (
{ account: Boolean (userFieldErrors.name?.length),
behavior: false,
theme: Boolean (settingsFieldErrors.theme?.length),
keyboard: conflictActionIds.size > 0,
behavior: false }),
keyboard: conflictActionIds.size > 0 }),
[conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name],
)
@@ -706,29 +766,65 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
applyThemeSelection (activeThemeSlot, themeSlots)
}
const confirmThemeSlotSwitch = async (
onConfirmed: (themeSlots: UserThemeSlotMap) => void,
): Promise<void> => {
if (!(hasUnsavedThemeChanges))
{
onConfirmed (draftThemeSlots)
return
}
const confirmed = await dialogue.confirm ({
title: '未保存の変更があります',
description: 'このまま切り替えると、保存していない変更は失われます。',
confirmText: '変更を破棄して切り替える',
cancelText: 'このまま編輯する',
})
if (!(confirmed))
return
const discardThemeChanges = () => {
setDraftActiveThemeSlot (savedActiveThemeSlot)
setDraftThemeSlots (savedThemeSlots)
setEditingThemeSlot (null)
onConfirmed (savedThemeSlots)
setCachedUserThemeSlots (savedThemeSlots)
setClientActiveThemeSlot (savedActiveThemeSlot)
applyClientAppearance (savedThemeSlots)
}
const requestActiveTab = async (
nextTab: SettingsTab,
): Promise<void> => {
if (nextTab === activeTab)
return
const currentDirtyState = dirtyStates[activeTab]
if (!(currentDirtyState?.dirty))
{
applyActiveTab (nextTab)
return
}
const confirmed = await confirmDiscardChanges (
'このタブを離れると、保存していない変更は失われます。',
'このタブに残る',
'変更を破棄して移動',
)
if (!(confirmed))
return
await currentDirtyState.discard ()
applyActiveTab (nextTab)
}
const requestThemeSlotSelection = async (
selection: ThemeSlotSelection,
editingSlot: UserThemeSlotSelection | null,
): Promise<void> => {
if (
selection === draftActiveThemeSlot
&& editingSlot === editingThemeSlot
)
return
if (hasUnsavedThemeChanges)
{
const confirmed = await confirmDiscardChanges (
'このまま切り替えると、保存していない変更は失われます。',
'この枠に残る',
'変更を破棄して切り替える',
)
if (!(confirmed))
return
discardThemeChanges ()
}
setEditingThemeSlot (editingSlot)
applyDraftThemeSelection (selection, savedThemeSlots)
}
const ensureEditableThemeSlot = async (): Promise<UserThemeSlotSelection | null> => {
@@ -756,7 +852,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
return null
setEditingThemeSlot (choice)
applyDraftThemeSelection (choice, draftThemeSlots)
applyDraftThemeSelection (choice, savedThemeSlots)
return choice
}
@@ -790,23 +886,23 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
case 'ArrowDown':
case 'ArrowRight':
event.preventDefault ()
setActiveTab (tabs[(currentIndex + 1) % tabs.length].id)
void requestActiveTab (tabs[(currentIndex + 1) % tabs.length].id)
break
case 'ArrowUp':
case 'ArrowLeft':
event.preventDefault ()
setActiveTab (tabs[(currentIndex - 1 + tabs.length) % tabs.length].id)
void requestActiveTab (tabs[(currentIndex - 1 + tabs.length) % tabs.length].id)
break
case 'Home':
event.preventDefault ()
setActiveTab (tabs[0].id)
void requestActiveTab (tabs[0].id)
break
case 'End':
event.preventDefault ()
setActiveTab (tabs[tabs.length - 1].id)
void requestActiveTab (tabs[tabs.length - 1].id)
break
}
}
@@ -889,7 +985,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
)
setClientActiveThemeSlot (draftActiveThemeSlot)
setCachedUserThemeSlots (completeThemeSlots)
applyThemeSelection (draftActiveThemeSlot, completeThemeSlots)
applyClientAppearance (completeThemeSlots)
toast ({ title: 'テーマ設定を保存しました.' })
}
catch (submitError)
@@ -900,14 +996,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
}
const handleThemeDiscard = () => {
setDraftActiveThemeSlot (savedActiveThemeSlot)
setDraftThemeSlots (savedThemeSlots)
setEditingThemeSlot (
isUserThemeSlotSelection (savedActiveThemeSlot)
? savedActiveThemeSlot
: null,
)
applyThemeSelection (savedActiveThemeSlot, savedThemeSlots)
discardThemeChanges ()
}
useEffect (() => {
@@ -985,12 +1074,12 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
useEffect (() => {
if (userFieldErrors.name?.length)
setActiveTab ('account')
applyActiveTab ('account')
}, [location.pathname, location.search, userFieldErrors.name])
useEffect (() => {
if (settingsFieldErrors.theme?.length)
setActiveTab ('theme')
applyActiveTab ('theme')
}, [location.pathname, location.search, settingsFieldErrors.theme])
useEffect (() => {
@@ -1015,6 +1104,28 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
}
: null
useEffect (() => {
updateTabDirtyState ('theme', {
dirty: hasUnsavedThemeChanges,
discard: handleThemeDiscard,
})
}, [hasUnsavedThemeChanges, savedActiveThemeSlot, savedThemeSlots])
useKeyboardShortcuts ({
'settings.account': () => {
void requestActiveTab ('account')
},
'settings.behavior': () => {
void requestActiveTab ('behavior')
},
'settings.theme': () => {
void requestActiveTab ('theme')
},
'settings.keyboard': () => {
void requestActiveTab ('keyboard')
},
})
const themeSectionProps: ThemeSectionProps = {
draftActiveThemeSlot,
draftThemeSlots,
@@ -1025,16 +1136,10 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
settingsBaseErrors,
settingsFieldErrors,
onSelectThemeSlot: selection => {
void confirmThemeSlotSwitch (themeSlots => {
setEditingThemeSlot (null)
applyDraftThemeSelection (selection, themeSlots)
})
void requestThemeSlotSelection (selection, null)
},
onEditThemeSlot: selection => {
void confirmThemeSlotSwitch (themeSlots => {
setEditingThemeSlot (selection)
applyDraftThemeSelection (selection, themeSlots)
})
void requestThemeSlotSelection (selection, selection)
},
onEnsureEditableTheme: () => {
void ensureEditableThemeSlot ()
@@ -1067,7 +1172,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
}) ()
},
onResetThemeSlot: selection => {
const baseTheme = draftThemeSlots[selection]?.baseTheme ?? selection.split (':')[1] as BuiltinThemeId
const baseTheme =
draftThemeSlots[selection]?.baseTheme
?? selection.split (':')[1] as BuiltinThemeId
updateDraftThemeSlot (
selection,
() => getDefaultUserThemeSlot (
@@ -1111,7 +1218,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
'dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900']
: ['border-border bg-background text-foreground']),
)}
onClick={() => setActiveTab (tab.id)}>
onClick={() => {
void requestActiveTab (tab.id)
}}>
{tab.label}
{settingsTabErrors[tab.id] ? ' !' : ''}
</button>))}
@@ -1125,9 +1234,17 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
<AccountSection {...accountSectionProps}/>)}
{activeTab === 'theme' && <ThemeSection {...themeSectionProps}/>}
{activeTab === 'keyboard' && (
<KeyboardSettingsSection sectionClassName={sectionClassName}/>)}
<KeyboardSettingsSection
sectionClassName={sectionClassName}
onDirtyStateChange={(dirty, discard) => {
updateTabDirtyState ('keyboard', { dirty, discard })
}}/>)}
{activeTab === 'behavior' && (
<BehaviourSettingsSection sectionClassName={sectionClassName}/>)}
<BehaviourSettingsSection
sectionClassName={sectionClassName}
onDirtyStateChange={(dirty, discard) => {
updateTabDirtyState ('behavior', { dirty, discard })
}}/>)}
</div>)}
</div>
@@ -1155,7 +1272,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
: ['border-slate-300 bg-white text-slate-700',
'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100']),
)}
onClick={() => setActiveTab (tab.id)}
onClick={() => {
void requestActiveTab (tab.id)
}}
onKeyDown={handleTabKeyDown}>
{tab.label}
{settingsTabErrors[tab.id] ? ' ⚠' : ''}
@@ -1174,9 +1293,17 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
<AccountSection {...accountSectionProps}/>)}
{activeTab === 'theme' && <ThemeSection {...themeSectionProps}/>}
{activeTab === 'keyboard' && (
<KeyboardSettingsSection sectionClassName={sectionClassName}/>)}
<KeyboardSettingsSection
sectionClassName={sectionClassName}
onDirtyStateChange={(dirty, discard) => {
updateTabDirtyState ('keyboard', { dirty, discard })
}}/>)}
{activeTab === 'behavior' && (
<BehaviourSettingsSection sectionClassName={sectionClassName}/>)}
<BehaviourSettingsSection
sectionClassName={sectionClassName}
onDirtyStateChange={(dirty, discard) => {
updateTabDirtyState ('behavior', { dirty, discard })
}}/>)}
</div>)}
</div>
</div>