Skip to content

Commit 8ec5a64

Browse files
authored
fix(layout): keep About/Settings rail and separator fixed while content scrolls (#205)
1 parent f33d238 commit 8ec5a64

3 files changed

Lines changed: 64 additions & 28 deletions

File tree

src/index.css

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -458,9 +458,16 @@ html[data-reduced-motion='reduce'] ::view-transition-new(root) {
458458
font-variant-numeric: tabular-nums;
459459
}
460460

461+
/* No `contain: layout` and no `overflow-x` here. Layout containment makes
462+
#root the containing block for every `position: fixed` descendant, so the
463+
navbar (and any fixed overlay) scrolls away with the document below lg.
464+
`overflow-x: hidden` silently computes overflow-y to `auto`, turning #root
465+
into a scrollport that never scrolls — which disables every `sticky`
466+
descendant (the About/Settings rail). Horizontal clipping is already owned
467+
by `html` and `body` (overflow-x: hidden + max-width: 100vw) above. */
461468
#root {
462-
@apply overflow-x-hidden w-full max-w-full;
463-
contain: layout style;
469+
@apply w-full max-w-full;
470+
contain: style;
464471
}
465472

466473
button {

src/pages/AboutPage/AboutPage.tsx

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { memo, useCallback, useEffect, useState } from 'react';
1+
import { memo, useCallback, useEffect, useRef, useState } from 'react';
22

33
import {
44
Code2,
@@ -71,6 +71,7 @@ const AboutPage = memo(function AboutPage() {
7171
: DEFAULT_TAB;
7272

7373
const [activeTab, setActiveTab] = useState(initialTab);
74+
const contentRef = useRef<HTMLDivElement>(null);
7475

7576
// Redirect an unknown tab key to the default so old/bad links land on a valid
7677
// section instead of an empty area.
@@ -94,27 +95,29 @@ const AboutPage = memo(function AboutPage() {
9495
(tabId: string) => {
9596
setActiveTab(tabId);
9697
setSearchParams({ tab: tabId });
98+
// Each section starts at its top, not wherever the previous one was left.
99+
contentRef.current?.scrollTo({ top: 0 });
97100
},
98101
[setSearchParams]
99102
);
100103

101104
return (
102-
<div
103-
data-page-scroll
104-
className="min-h-full bg-bg md:h-full md:max-h-full md:overflow-y-auto"
105-
>
105+
<div className="min-h-full bg-bg md:h-full md:max-h-full">
106106
<Seo
107107
{...getRouteSeo('/about')}
108108
schema={[WEBSITE_SCHEMA, ORGANIZATION_SCHEMA]}
109109
/>
110110
<h1 className="sr-only">About ChessVision</h1>
111-
{/* Two-column shell, constrained to the navbar's width so the page reads
112-
as one column under the bar: a sticky left category rail (always
113-
visible, never collapses) and a scrolling content column on the right
114-
(GitHub-settings pattern), mirroring the Settings page layout. */}
115-
<div className="page-container flex flex-col gap-6 py-6 sm:py-10 md:flex-row md:gap-8 lg:gap-10">
116-
<div className="shrink-0 mb-6 md:mb-0 md:border-r md:border-border md:pr-8 md:w-52 lg:w-56">
117-
<div className="md:sticky md:top-10">
111+
{/* Two-column split pane, constrained to the navbar's width so the page
112+
reads as one column under the bar (GitHub-settings pattern, mirroring
113+
the Settings page layout). At lg+ the shell locks the outer page, the
114+
row fills the visible height and ONLY the right content column
115+
scrolls — the rail and the separator stay put. Below lg the window
116+
scrolls and the rail falls back to `sticky`, offset past the fixed
117+
navbar. */}
118+
<div className="page-container flex flex-col gap-6 py-6 sm:py-10 md:h-full md:min-h-0 md:flex-row md:gap-8 lg:gap-10">
119+
<div className="shrink-0 mb-6 md:mb-0 md:w-52 lg:w-56">
120+
<div className="md:sticky md:top-[calc(var(--navbar-height)+1.5rem)]">
118121
<PageTabs
119122
groups={groups}
120123
activeId={activeTab}
@@ -124,13 +127,23 @@ const AboutPage = memo(function AboutPage() {
124127
</div>
125128
</div>
126129

130+
{/* Separator as its own flex child (not a border on the rail): at lg+
131+
it spans the row's padded content box, so it starts below the navbar
132+
and stops above the bottom instead of running edge to edge. */}
133+
<div
134+
aria-hidden="true"
135+
className="hidden md:block w-px shrink-0 self-stretch bg-border"
136+
/>
137+
127138
{/* Scroll region, NOT a landmark: the app shell already owns the single
128139
`<main id="main-content">`. A second `<main>` is an ARIA landmark
129140
violation (1.3.1). `role="region"` + label keeps it navigable. */}
130141
<div
142+
ref={contentRef}
143+
data-page-scroll
131144
role="region"
132145
aria-label="About content"
133-
className="min-w-0 flex-1"
146+
className="min-w-0 flex-1 md:min-h-0 lg:overflow-y-auto"
134147
>
135148
{activeTab === 'about' && <AboutMainSection />}
136149
{activeTab === 'changelog' && <ChangelogSection />}

src/pages/SettingsPage/SettingsPage.tsx

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
1+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
22

33
import {
44
Accessibility,
@@ -72,6 +72,7 @@ const SettingsPage = memo(function SettingsPage() {
7272
requestedTab && validTabIds.has(requestedTab) ? requestedTab : defaultTab;
7373

7474
const [activeTab, setActiveTab] = useState(initialTab);
75+
const contentRef = useRef<HTMLDivElement>(null);
7576

7677
// Redirect a retired tab key (e.g. ?tab=developer, ?tab=export) to the default
7778
// tab so old bookmarks land on a valid section instead of an empty area.
@@ -95,6 +96,8 @@ const SettingsPage = memo(function SettingsPage() {
9596
(tabId: string) => {
9697
setActiveTab(tabId);
9798
setSearchParams({ tab: tabId });
99+
// Each section starts at its top, not wherever the previous one was left.
100+
contentRef.current?.scrollTo({ top: 0 });
98101
},
99102
[setSearchParams]
100103
);
@@ -104,18 +107,17 @@ const SettingsPage = memo(function SettingsPage() {
104107
.find((item) => item.id === activeTab)?.label;
105108

106109
return (
107-
<div
108-
data-page-scroll
109-
className="min-h-full bg-bg md:h-full md:max-h-full md:overflow-y-auto"
110-
>
110+
<div className="min-h-full bg-bg md:h-full md:max-h-full">
111111
<Seo name={activeTabLabel} noindex />
112-
{/* Two-column shell, constrained to the navbar's width so the page reads
113-
as one column under the bar: a sticky left section rail (always
114-
visible, never collapses) and a scrolling content column on the right
115-
(GitHub-settings pattern). */}
116-
<div className="page-container flex flex-col gap-6 py-6 sm:py-8 md:flex-row md:gap-8 lg:gap-10">
117-
<div className="shrink-0 mb-6 md:mb-0 md:border-r md:border-border md:pr-8 md:w-52 lg:w-56">
118-
<div className="md:sticky md:top-8">
112+
{/* Two-column split pane, constrained to the navbar's width so the page
113+
reads as one column under the bar (GitHub-settings pattern). At lg+
114+
the shell locks the outer page, the row fills the visible height and
115+
ONLY the right content column scrolls — the rail and the separator
116+
stay put. Below lg the window scrolls and the rail falls back to
117+
`sticky`, offset past the fixed navbar. */}
118+
<div className="page-container flex flex-col gap-6 py-6 sm:py-8 md:h-full md:min-h-0 md:flex-row md:gap-8 lg:gap-10">
119+
<div className="shrink-0 mb-6 md:mb-0 md:w-52 lg:w-56">
120+
<div className="md:sticky md:top-[calc(var(--navbar-height)+1.5rem)]">
119121
<PageTabs
120122
groups={groups}
121123
activeId={activeTab}
@@ -125,10 +127,24 @@ const SettingsPage = memo(function SettingsPage() {
125127
</div>
126128
</div>
127129

130+
{/* Separator as its own flex child (not a border on the rail): at lg+
131+
it spans the row's padded content box, so it starts below the navbar
132+
and stops above the bottom instead of running edge to edge. */}
133+
<div
134+
aria-hidden="true"
135+
className="hidden md:block w-px shrink-0 self-stretch bg-border"
136+
/>
137+
128138
{/* Scroll region, NOT a landmark: the app shell already owns the single
129139
`<main id="main-content">`. A second `<main>` is an ARIA landmark
130140
violation (1.3.1). `role="region"` + label keeps it navigable. */}
131-
<div role="region" aria-label="Settings" className="min-w-0 flex-1">
141+
<div
142+
ref={contentRef}
143+
data-page-scroll
144+
role="region"
145+
aria-label="Settings"
146+
className="min-w-0 flex-1 md:min-h-0 lg:overflow-y-auto"
147+
>
132148
{activeTab === 'profile' && <AccountSection />}
133149
{activeTab === 'appearance' && <AppearanceSection />}
134150
{activeTab === 'board' && <BoardSection />}

0 commit comments

Comments
 (0)