diff --git a/e2e/right-pane-scroll.spec.ts b/e2e/right-pane-scroll.spec.ts new file mode 100644 index 0000000..75b4015 --- /dev/null +++ b/e2e/right-pane-scroll.spec.ts @@ -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() + }) +}) diff --git a/src/App.tsx b/src/App.tsx index 7ae43c9..15c6ace 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -95,24 +95,30 @@ function RightPane() { const view = resolveExamViewMode(semester, now) return ( -
-
+ // 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. +
+
- {view === 'exam' ? ( - - ) : ( - <> - -
-

- Homework -

- -
- - - )} +
+ {view === 'exam' ? ( + + ) : ( + <> + +
+

+ Homework +

+ +
+ + + )} +
) } diff --git a/src/features/layout/AppShell.tsx b/src/features/layout/AppShell.tsx index b340020..6e409df 100644 --- a/src/features/layout/AppShell.tsx +++ b/src/features/layout/AppShell.tsx @@ -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)') @@ -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 =
{left}
- const rightInner =
{right}
+ const rightInner =
{right}
if (roomy) { return ( @@ -46,7 +52,7 @@ export function AppShell({ left, right }: { left: ReactNode; right: ReactNode }) style={{ gridTemplateColumns: 'minmax(0, 55fr) minmax(0, 45fr)' }} >
{leftInner}
-
{rightInner}
+
{rightInner}
) } @@ -65,7 +71,7 @@ export function AppShell({ left, right }: { left: ReactNode; right: ReactNode })
- + {rightInner} diff --git a/src/styles/theme.css b/src/styles/theme.css index 222d28b..eb2e714 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -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; diff --git a/tsconfig.node.json b/tsconfig.node.json index 3396985..6569e83 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -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,