Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions e2e/right-pane-scroll.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { test, expect, type Page } from '@playwright/test'

async function createSemester(page: Page) {
await page.goto('/')
await page.getByRole('button', { name: 'Create your first semester' }).click()
await page.getByRole('button', { name: 'Create Semester' }).click()
await expect(page.getByRole('heading', { name: 'Weekly Schedule' })).toBeVisible()
}

const viewSwitch = (page: Page) => page.getByRole('group', { name: 'Right panel view' })
// The right pane's scroll field: the homework list's parent (see RightPane).
const scrollField = (page: Page) => page.locator('#homework-list').locator('xpath=..')

/** The Schedule|Exams switch is pinned; only the content under it scrolls, with no visible bar. */
test.describe('right pane scrolling', () => {
for (const width of [1440, 1100]) {
test(`desktop @${width}px: the view switch stays put while the pane scrolls`, async ({
page,
}) => {
await page.setViewportSize({ width, height: 800 })
await createSemester(page)

const field = scrollField(page)
const geom = await field.evaluate((el: HTMLElement) => ({
overflows: el.scrollHeight > el.clientHeight,
scrollbarWidth: el.offsetWidth - el.clientWidth,
overflowY: getComputedStyle(el).overflowY,
}))
expect(geom.overflows).toBe(true)
expect(geom.overflowY).toBe('auto')
expect(geom.scrollbarWidth).toBe(0)

const before = await viewSwitch(page).boundingBox()
await field.evaluate((el: HTMLElement) => el.scrollBy(0, 400))
expect(await field.evaluate((el: HTMLElement) => el.scrollTop)).toBeGreaterThan(0)

const after = await viewSwitch(page).boundingBox()
expect(after!.y).toBeCloseTo(before!.y, 0)

// The window itself never scrolls on desktop — each pane owns its scrolling.
const pageOverflow = await page.evaluate(
() => document.documentElement.scrollHeight - document.documentElement.clientHeight,
)
expect(pageOverflow).toBeLessThanOrEqual(0)
})
}

test('mobile: the view switch sticks to the top of the viewport', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await createSemester(page)

await page.getByRole('heading', { name: 'Weekly Schedule' }).scrollIntoViewIfNeeded()
await page.mouse.wheel(0, 1200)
await expect
.poll(async () => (await viewSwitch(page).boundingBox())!.y, { timeout: 3000 })
.toBeLessThan(40)
await expect(viewSwitch(page)).toBeInViewport()
})
})
38 changes: 22 additions & 16 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,24 +95,30 @@ function RightPane() {
const view = resolveExamViewMode(semester, now)

return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
// Mirrors LeftPane: on desktop the pane fills its height and only the content
// below the view switch scrolls. On mobile (stacked, one page-long scroll) the
// switch sticks to the top of the viewport while this column is in view, so it
// is reachable from anywhere in the schedule/exams content either way.
<div className="flex flex-col lg:h-full">
<div className="sticky top-0 z-20 flex justify-end bg-surface pt-2 pb-3 lg:static lg:pt-0 lg:pb-4">
<ViewToggle />
</div>
{view === 'exam' ? (
<ExamRoadmap />
) : (
<>
<WeekCalendar />
<section id="homework-list">
<h3 className="mb-2 text-[13px] font-semibold tracking-[0.5px] text-ink-faint uppercase">
Homework
</h3>
<HomeworkList />
</section>
<ScrollToHomeworkButton />
</>
)}
<div className="flex flex-col gap-4 lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pb-16 scrollbar-hidden">
{view === 'exam' ? (
<ExamRoadmap />
) : (
<>
<WeekCalendar />
<section id="homework-list" className="scroll-mt-16">
<h3 className="mb-2 text-[13px] font-semibold tracking-[0.5px] text-ink-faint uppercase">
Homework
</h3>
<HomeworkList />
</section>
<ScrollToHomeworkButton />
</>
)}
</div>
</div>
)
}
Expand Down
14 changes: 10 additions & 4 deletions src/features/layout/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ import { useMediaQuery } from '@/hooks/useMediaQuery'
* appears, persisted via autoSaveId ('tollab-split-v3').
* - ≤ 1023px: the panes stack into one scrolling column.
*
* Both panes cap their content at max-w-4xl and scroll independently.
* Both panes cap their content at max-w-4xl and fill the viewport height; each
* owns its scrolling internally (see LeftPane/RightPane), so their headers stay
* pinned while only the content below them scrolls.
*/
export function AppShell({ left, right }: { left: ReactNode; right: ReactNode }) {
const stacked = useMediaQuery('(max-width: 1023px)')
Expand All @@ -36,8 +38,12 @@ export function AppShell({ left, right }: { left: ReactNode; right: ReactNode })
// the window edge instead of running flush to it. Shared by both desktop
// branches so the fixed and draggable splits are pixel-identical apart from the
// handle.
//
// The right pane gets no bottom padding here: it is a full-height column whose
// own scroll field (inside RightPane) carries the bottom breathing room, so the
// padding scrolls with the content rather than clipping the pinned view switch.
const leftInner = <div className="mx-auto h-full w-full max-w-4xl p-8">{left}</div>
const rightInner = <div className="mx-auto w-full max-w-4xl px-6 pt-8 pb-16">{right}</div>
const rightInner = <div className="mx-auto h-full w-full max-w-4xl px-6 pt-8">{right}</div>

if (roomy) {
return (
Expand All @@ -46,7 +52,7 @@ export function AppShell({ left, right }: { left: ReactNode; right: ReactNode })
style={{ gridTemplateColumns: 'minmax(0, 55fr) minmax(0, 45fr)' }}
>
<div className="overflow-hidden">{leftInner}</div>
<div className="overflow-y-auto [scrollbar-gutter:stable]">{rightInner}</div>
<div className="overflow-hidden">{rightInner}</div>
</div>
)
}
Expand All @@ -65,7 +71,7 @@ export function AppShell({ left, right }: { left: ReactNode; right: ReactNode })
<span className="size-[3px] rounded-full bg-ink-faint" />
</div>
</PanelResizeHandle>
<Panel defaultSize={45} minSize={25} className="!overflow-y-auto [scrollbar-gutter:stable]">
<Panel defaultSize={45} minSize={25} className="!overflow-hidden">
{rightInner}
</Panel>
</PanelGroup>
Expand Down
10 changes: 10 additions & 0 deletions src/styles/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,16 @@
text-transform: uppercase;
}

/* A scroll field with no scrollbar chrome. Wheel, touch, and keyboard scrolling
all still work; only the bar itself is hidden, so a pane can scroll without a
rule down its edge. */
@utility scrollbar-hidden {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}

@layer base {
body {
@apply bg-surface text-ink antialiased;
Expand Down
5 changes: 4 additions & 1 deletion tsconfig.node.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
// DOM is here for the e2e specs only: Playwright's page/locator.evaluate
// callbacks are typed against browser globals even though the test file runs
// in Node.
"lib": ["ES2023", "DOM"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
Expand Down
Loading