feat(settings): center jumped-to sidebar sections and flash their border (#795)

This commit is contained in:
Neil 2026-04-17 23:30:47 -07:00 committed by GitHub
parent f2eb4f4866
commit a4899dbbf1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 66 additions and 7 deletions

View file

@ -672,6 +672,33 @@
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);
box-shadow:
0 0 0 2px color-mix(in srgb, var(--ring) 45%, transparent),
0 1px 2px 0 rgb(0 0 0 / 0.05);
}
60% {
border-color: var(--ring);
box-shadow:
0 0 0 2px color-mix(in srgb, var(--ring) 30%, transparent),
0 1px 2px 0 rgb(0 0 0 / 0.05);
}
100% {
border-color: var(--border);
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
}
}
.settings-section-flash {
animation: settings-section-flash 900ms ease-out;
}
.sidebar.collapsed {
width: 0;
border-right: none;

View file

@ -70,6 +70,41 @@ function getFallbackVisibleSection(sections: SettingsNavSection[]): SettingsNavS
return sections.at(0)
}
// 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 scrollSectionIntoView(sectionId: string, container: HTMLElement | null): void {
const target = document.getElementById(sectionId)
if (!target) {
return
}
// Why: centering a tall section pushes its heading above the viewport,
// which defeats the purpose of jumping to it. Only center when the whole
// section fits; otherwise align to the top so the title is always visible.
const fitsInViewport = container
? target.getBoundingClientRect().height <= container.clientHeight
: true
target.scrollIntoView({ block: fitsInViewport ? 'center' : 'start' })
}
function flashSectionHighlight(sectionId: string): void {
const target = document.getElementById(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)
}
function Settings(): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const updateSettings = useAppStore((s) => s.updateSettings)
@ -337,8 +372,8 @@ function Settings(): React.JSX.Element {
const visibleIds = new Set(visibleNavSections.map((section) => section.id))
if (scrollTargetId && pendingNavSectionId && visibleIds.has(pendingNavSectionId)) {
const target = document.getElementById(scrollTargetId)
target?.scrollIntoView({ block: 'start' })
scrollSectionIntoView(scrollTargetId, contentScrollRef.current)
flashSectionHighlight(scrollTargetId)
setActiveSectionId(pendingNavSectionId)
pendingNavSectionRef.current = null
pendingScrollTargetRef.current = null
@ -430,11 +465,8 @@ function Settings(): React.JSX.Element {
}, [visibleNavSections])
const scrollToSection = useCallback((sectionId: string) => {
const target = document.getElementById(sectionId)
if (!target) {
return
}
target.scrollIntoView({ block: 'start' })
scrollSectionIntoView(sectionId, contentScrollRef.current)
flashSectionHighlight(sectionId)
setActiveSectionId(sectionId)
}, [])