Show one focused settings pane at a time (#2451)
This commit is contained in:
parent
f25741b372
commit
288d96be03
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 "{settingsSearchQuery.trim()}"
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ActiveSettingsSectionProvider value={activeSectionId}>
|
||||
<SettingsSection
|
||||
id="general"
|
||||
title="General"
|
||||
|
|
@ -1412,7 +1333,7 @@ function Settings(): React.JSX.Element {
|
|||
</SettingsSection>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
</ActiveSettingsSectionProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue