Show one focused settings pane at a time (#2451)

This commit is contained in:
Jinjing 2026-05-20 14:28:30 -07:00 committed by GitHub
parent f25741b372
commit 288d96be03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 71 additions and 161 deletions

View File

@ -800,32 +800,6 @@
animation: settings-shell-enter 180ms ease-out;
}
/* Why: after clicking a sidebar item we center the target section and briefly
pulse its border so the user can see exactly which section their click
landed on the scroll destination alone is subtle when the section is
already partially visible. */
@keyframes settings-section-flash {
0% {
border-color: var(--ring);
background: color-mix(in srgb, var(--ring) 8%, transparent);
box-shadow: inset 3px 0 0 var(--ring);
}
60% {
border-color: var(--ring);
background: color-mix(in srgb, var(--ring) 5%, transparent);
box-shadow: inset 3px 0 0 color-mix(in srgb, var(--ring) 70%, transparent);
}
100% {
border-color: var(--border);
background: transparent;
box-shadow: inset 0 0 0 transparent;
}
}
.settings-section-flash {
animation: settings-section-flash 900ms ease-out;
}
.sidebar.collapsed {
width: 0;
border-right: none;

View File

@ -76,7 +76,7 @@ import {
import { PrivacyPane } from './PrivacyPane'
import { PRIVACY_PANE_SEARCH_ENTRIES } from './privacy-search'
import { SettingsSidebar } from './SettingsSidebar'
import { SettingsSection } from './SettingsSection'
import { ActiveSettingsSectionProvider, SettingsSection } from './SettingsSection'
import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search'
import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client'
import {
@ -158,13 +158,6 @@ function computerUsePlatformLabel(args: { isWindows: boolean; isMac: boolean }):
return 'This platform'
}
// Why: after a sidebar jump the target section is now in the viewport center
// rather than the top, which can make it less obvious which section just
// scrolled into view. Pulsing the border for a moment reassures the user that
// their click landed on the right section.
const SECTION_FLASH_CLASS = 'settings-section-flash'
const SECTION_FLASH_DURATION_MS = 900
function getSettingsScrollTarget(
sectionId: string,
container?: HTMLElement | null
@ -175,8 +168,12 @@ function getSettingsScrollTarget(
)
}
function scrollSectionIntoView(sectionId: string, container?: HTMLElement | null): void {
const target = getSettingsScrollTarget(sectionId, container)
function scrollSubsectionIntoView(targetId: string, container?: HTMLElement | null): void {
// Why: deep links into Settings can target a specific subsection inside a
// pane (e.g. a particular row). The pane itself is now swapped in
// wholesale, so this only needs to nudge the inner scroll if the pane has
// grown taller than the viewport.
const target = getSettingsScrollTarget(targetId, container)
if (!target) {
return
}
@ -187,27 +184,8 @@ function scrollSectionIntoView(sectionId: string, container?: HTMLElement | null
const containerRect = container.getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
const targetTop = targetRect.top - containerRect.top + container.scrollTop
// Why: the scroll spy samples 40% down the viewport. Put sidebar jump
// targets just above that probe so short sections like Voice do not
// immediately hand active selection to the next section.
const desiredTop = targetTop - container.clientHeight * 0.3
const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight)
container.scrollTo({ top: Math.min(Math.max(0, desiredTop), maxScrollTop) })
}
function flashSectionHighlight(sectionId: string): void {
const target = getSettingsScrollTarget(sectionId)
if (!target) {
return
}
target.classList.remove(SECTION_FLASH_CLASS)
// Force a reflow so re-adding the class restarts the animation.
void target.offsetWidth
target.classList.add(SECTION_FLASH_CLASS)
window.setTimeout(() => {
target.classList.remove(SECTION_FLASH_CLASS)
}, SECTION_FLASH_DURATION_MS)
container.scrollTo({ top: Math.min(Math.max(0, targetTop - 16), maxScrollTop) })
}
function isEditableTarget(target: EventTarget | null): boolean {
@ -868,29 +846,40 @@ function Settings(): React.JSX.Element {
const scrollTargetId = pendingScrollTargetRef.current
const pendingNavSectionId = pendingNavSectionRef.current
if (scrollTargetId && pendingNavSectionId && visibleSectionIds.has(pendingNavSectionId)) {
// Why: target navigation can arrive before the lazy section has mounted;
// keep the pending refs alive until the mounted-section update commits.
if (!getSettingsScrollTarget(scrollTargetId, contentScrollRef.current)) {
return
}
const scrollToPendingTarget = (): void => {
scrollSectionIntoView(scrollTargetId, contentScrollRef.current)
flashSectionHighlight(scrollTargetId)
}
scrollToPendingTarget()
// Why: mounting the target section can change settings-page height as
// panes hydrate, so repeat once after layout settles.
requestAnimationFrame(scrollToPendingTarget)
window.setTimeout(scrollToPendingTarget, 150)
setActiveSectionId(pendingNavSectionId)
pendingNavSectionRef.current = null
pendingScrollTargetRef.current = null
if (scrollTargetId && pendingNavSectionId && settingsSearchQuery.trim() !== '') {
setSettingsSearchQuery('')
return
}
if (scrollTargetId && pendingNavSectionId && settingsSearchQuery.trim() !== '') {
setSettingsSearchQuery('')
if (scrollTargetId && pendingNavSectionId && visibleSectionIds.has(pendingNavSectionId)) {
// Why: inactive Settings panes no longer render in the empty-search view.
// Activate the pane first, then wait for the next render before looking
// for any subsection target inside it.
if (activeSectionId !== pendingNavSectionId) {
setActiveSectionId(pendingNavSectionId)
return
}
const container = contentScrollRef.current
if (container) {
container.scrollTo({ top: 0 })
}
// Why: deep links can target a row inside the pane; the pane itself is
// already in view because the sidebar swap rendered just it.
if (scrollTargetId !== pendingNavSectionId) {
// Why: target navigation can arrive before the lazy section has mounted;
// keep the pending refs alive until the mounted-section update commits.
if (!getSettingsScrollTarget(scrollTargetId, container)) {
return
}
const scrollToSubsection = (): void => {
scrollSubsectionIntoView(scrollTargetId, contentScrollRef.current)
}
scrollToSubsection()
requestAnimationFrame(scrollToSubsection)
}
setActiveSectionId(pendingNavSectionId)
pendingNavSectionRef.current = null
pendingScrollTargetRef.current = null
return
}
@ -906,76 +895,6 @@ function Settings(): React.JSX.Element {
visibleNavSections
])
useEffect(() => {
const container = contentScrollRef.current
if (!container) {
return
}
const updateActiveSection = (): void => {
const sections = Array.from(
container.querySelectorAll<HTMLElement>('[data-settings-section]')
)
if (sections.length === 0) {
return
}
// Why: highlight the section that the user is actually reading.
// We pick the section whose body crosses a probe line ~40% down the
// viewport (roughly the middle, biased slightly up toward where the
// eye naturally focuses). Earlier logic used the first section with
// its top near the container top, which lagged badly — a section
// could still fill most of the viewport while the sidebar had already
// advanced to the next one.
const containerRect = container.getBoundingClientRect()
const probeY = containerRect.top + containerRect.height * 0.4
// If we've scrolled to the very bottom, force-highlight the last
// section even when it's too short to reach the probe line.
const atBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 2
let candidate: HTMLElement | undefined
for (const section of sections) {
const rect = section.getBoundingClientRect()
if (rect.top <= probeY && rect.bottom > probeY) {
candidate = section
break
}
if (rect.top <= probeY) {
// Last section whose heading is above the probe line — used
// when no section straddles the probe (e.g. between sections,
// or when the probe sits in the gutter above the first one).
candidate = section
}
}
candidate ??= atBottom ? sections.at(-1) : sections.at(0)
if (!candidate) {
return
}
setActiveSectionId(candidate.dataset.settingsSection ?? candidate.id)
}
let rafId: number | null = null
const throttledUpdateActiveSection = (): void => {
if (rafId !== null) {
return
}
rafId = requestAnimationFrame(() => {
rafId = null
updateActiveSection()
})
}
updateActiveSection()
container.addEventListener('scroll', throttledUpdateActiveSection, { passive: true })
return () => {
container.removeEventListener('scroll', throttledUpdateActiveSection)
if (rafId !== null) {
cancelAnimationFrame(rafId)
}
}
}, [visibleNavSections])
const scrollToSection = useCallback(
async (
sectionId: string,
@ -992,8 +911,10 @@ function Settings(): React.JSX.Element {
if (sectionId === 'experimental' && modifiers?.shiftKey) {
setHiddenExperimentalUnlocked((previous) => !previous)
}
scrollSectionIntoView(sectionId, contentScrollRef.current)
flashSectionHighlight(sectionId)
const container = contentScrollRef.current
if (container) {
container.scrollTo({ top: 0 })
}
setActiveSectionId(sectionId)
},
[activeSectionId, confirmDiscardCommitPromptChanges]
@ -1057,7 +978,7 @@ function Settings(): React.JSX.Element {
No settings found for &quot;{settingsSearchQuery.trim()}&quot;
</div>
) : (
<>
<ActiveSettingsSectionProvider value={activeSectionId}>
<SettingsSection
id="general"
title="General"
@ -1412,7 +1333,7 @@ function Settings(): React.JSX.Element {
</SettingsSection>
)
})}
</>
</ActiveSettingsSectionProvider>
)}
</div>
</div>

View File

@ -1,8 +1,15 @@
import type React from 'react'
import { createContext, useContext } from 'react'
import { useAppStore } from '../../store'
import type { SettingsSearchEntry } from './settings-search'
import { matchesSettingsSearch } from './settings-search'
// Why: avoids threading `activeSectionId` through every <SettingsSection /> call
// site in Settings.tsx — the page wraps its content tree in this provider.
const ActiveSettingsSectionContext = createContext<string | null>(null)
export const ActiveSettingsSectionProvider = ActiveSettingsSectionContext.Provider
type SettingsSectionProps = {
id: string
title: string
@ -13,6 +20,11 @@ type SettingsSectionProps = {
badge?: string
badgeAccessory?: React.ReactNode
forceVisible?: boolean
/** When true, this section is the one currently selected in the sidebar.
* Sections render only when active or when a non-empty search matches them
* that way the Settings page shows one focused pane at a time instead of
* one giant scrolling document. */
isActive?: boolean
/** Rendered in the section header's upper-right corner intended for
* section-scoped actions (e.g. "Import from Ghostty") that would otherwise
* crowd the settings list as their own row. */
@ -29,23 +41,26 @@ export function SettingsSection({
badge,
badgeAccessory,
forceVisible = false,
isActive,
headerAction
}: SettingsSectionProps): React.JSX.Element | null {
const query = useAppStore((state) => state.settingsSearchQuery)
if (!forceVisible && searchEntries && !matchesSettingsSearch(query, searchEntries)) {
return null
const activeFromContext = useContext(ActiveSettingsSectionContext)
const sectionIsActive = isActive ?? activeFromContext === id
const hasQuery = query.trim() !== ''
const matchesQuery = !searchEntries || matchesSettingsSearch(query, searchEntries)
if (!forceVisible) {
if (hasQuery) {
if (!matchesQuery) {
return null
}
} else if (!sectionIsActive) {
return null
}
}
return (
<section
id={id}
data-settings-section={id}
className={
// Why: each pane already owns internal cards and borders. A stronger unframed section
// break keeps top-level settings pages distinct without nesting everything in cards.
className ?? 'scroll-mt-6 space-y-6 border-b-2 border-foreground/20 pb-10 last:border-b-0'
}
>
<section id={id} data-settings-section={id} className={className ?? 'scroll-mt-6 space-y-6'}>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<h2 className="flex items-center gap-2 text-lg font-semibold">