diff --git a/frontend/src/components/CategoryColumns.css b/frontend/src/components/CategoryColumns.css index 135192a..8363c2a 100644 --- a/frontend/src/components/CategoryColumns.css +++ b/frontend/src/components/CategoryColumns.css @@ -20,8 +20,30 @@ cursor: pointer; } -.cc-columns { display: flex; gap: 14px; align-items: flex-start; overflow-x: auto; } -.cc-column-slot { flex: 0 0 clamp(220px, 32%, 320px); min-width: 0; } +.cc-columns { + display: flex; gap: 14px; align-items: flex-start; + overflow-x: auto; scroll-behavior: smooth; + /* Padding on a scroll container is not part of its scrollable width in every + browser, so the last column used to sit flush against the clipped edge + with nothing beyond it to scroll to. The spacer element supplies the room + instead, which every browser counts. */ + scroll-padding-inline-end: 14px; + padding-bottom: 4px; +} +/* A fixed width, not a percentage. `32%` resolved against the *visible* width, + so the columns shrank as more of them opened and the strip never quite + admitted how much there was to scroll. */ +.cc-column-slot { flex: 0 0 min(300px, 78vw); min-width: 0; } +/* Keeps the last column clear of the edge without relying on container padding. */ +.cc-columns::after { content: ''; flex: 0 0 4px; } + +/* The strip scrolls; the scrollbar is what says so. Hiding it left no way to + reach the far column with a mouse, since a vertical wheel does not move a + horizontal overflow. */ +.cc-columns { scrollbar-width: thin; } +.cc-columns::-webkit-scrollbar { height: 10px; } +.cc-columns::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; } +.cc-columns::-webkit-scrollbar-thumb:hover { background: var(--text-subtle); } .cc-column { background: var(--card-bg); diff --git a/frontend/src/components/CategoryColumns.jsx b/frontend/src/components/CategoryColumns.jsx index 5785461..10b7e59 100644 --- a/frontend/src/components/CategoryColumns.jsx +++ b/frontend/src/components/CategoryColumns.jsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import './CategoryColumns.css' /** A folder of further topics. */ @@ -126,6 +126,19 @@ export default function CategoryColumns({ // Narrow screens show the deepest column only, with a way back up. const deepest = columns.length - 1 + // Opening a category adds a column off the right-hand edge. Without this it + // is simply not on screen, and nothing says a strip that already fills the + // window has more to the right of it. + const strip = useRef(null) + useEffect(() => { + const box = strip.current + if (!box) return + const last = box.lastElementChild + if (typeof last?.scrollIntoView === 'function') { + last.scrollIntoView({ block: 'nearest', inline: 'end', behavior: 'smooth' }) + } + }, [columns.length]) + return (
{path.length > 0 && ( @@ -134,7 +147,7 @@ export default function CategoryColumns({ ‹ {byId[path[path.length - 2]]?.name || allLabel} )} -
+
{columns.map((column, level) => (
diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index f617ee0..2d39dbf 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react' import { Link, useLocation } from 'react-router-dom' +import ScrollStrip from './ScrollStrip' import { useAuth } from '../context/AuthContext' import api from '../api/client' import ExamSwitcher from './ExamSwitcher' @@ -178,14 +179,18 @@ export default function Navbar({ onSignIn, onRegister }) {
window.location.reload()} /> - +
)} diff --git a/frontend/src/components/ScrollStrip.css b/frontend/src/components/ScrollStrip.css new file mode 100644 index 0000000..e5007ec --- /dev/null +++ b/frontend/src/components/ScrollStrip.css @@ -0,0 +1,37 @@ +/* A strip that scrolls sideways, with arrows that say so. */ + +.ss-wrap { position: relative; display: flex; flex: 1; min-width: 0; align-items: center; } + +.ss-strip { + display: flex; align-items: center; gap: 2px; + flex: 1; min-width: 0; + overflow-x: auto; overflow-y: hidden; + scrollbar-width: none; scroll-behavior: smooth; + -webkit-overflow-scrolling: touch; +} +.ss-strip::-webkit-scrollbar { display: none; } + +/* The arrows are the affordance, so the scrollbar stays hidden here — unlike a + content strip, a nav bar with a scrollbar through it looks broken. */ +.ss-arrow { + position: absolute; top: 50%; transform: translateY(-50%); z-index: 2; + width: 26px; height: 30px; padding: 0; + display: inline-flex; align-items: center; justify-content: center; + font: inherit; font-size: 1.1rem; line-height: 1; + color: var(--text-muted); cursor: pointer; + background: var(--bg); border: 1px solid var(--border); border-radius: 7px; + box-shadow: 0 1px 4px rgba(15, 23, 42, 0.12); +} +.ss-arrow:hover { color: var(--primary); border-color: var(--primary); } +.ss-arrow.is-start { left: -2px; } +.ss-arrow.is-end { right: -2px; } + +/* Room for the arrow to sit over, so it never covers a whole label. */ +.ss-wrap:has(.ss-arrow.is-start) .ss-strip { padding-left: 28px; } +.ss-wrap:has(.ss-arrow.is-end) .ss-strip { padding-right: 28px; } + +@media (max-width: 720px) { + /* Touch scrolls directly; the arrows would only take up room. */ + .ss-arrow { display: none; } + .ss-wrap:has(.ss-arrow) .ss-strip { padding-left: 0; padding-right: 0; } +} diff --git a/frontend/src/components/ScrollStrip.jsx b/frontend/src/components/ScrollStrip.jsx new file mode 100644 index 0000000..877de0d --- /dev/null +++ b/frontend/src/components/ScrollStrip.jsx @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import './ScrollStrip.css' + +/** + * A horizontal strip of things that scrolls, with a way to actually scroll it. + * + * The section bar hid its scrollbar and faded the right-hand edge, which says + * "there is more" and gives you nothing to do about it: a vertical wheel does + * not move a horizontal overflow, so with a plain mouse the last few links — + * Courses, Settings — were simply unreachable. + * + * So: arrows at each end, shown only while there is somewhere to go, and the + * wheel mapped onto the axis that actually scrolls. The current item is + * scrolled into view on arrival, because the page you are on should be visible + * in the bar that says where you are. + */ +export default function ScrollStrip({ children, className = '', label, step = 220, as: Tag = 'div', role }) { + const box = useRef(null) + const [edges, setEdges] = useState({ start: false, end: false }) + + const measure = useCallback(() => { + const el = box.current + if (!el) return + const max = el.scrollWidth - el.clientWidth + // A pixel of slack: sub-pixel layout leaves scrollLeft a hair short of max + // and the end arrow would never switch off. + setEdges({ start: el.scrollLeft > 1, end: el.scrollLeft < max - 1 }) + }, []) + + useEffect(() => { + const el = box.current + if (!el) return undefined + measure() + el.addEventListener('scroll', measure, { passive: true }) + window.addEventListener('resize', measure) + // Links can arrive after the first paint (a role loading, a count filling in). + const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measure) : null + observer?.observe(el) + return () => { + el.removeEventListener('scroll', measure) + window.removeEventListener('resize', measure) + observer?.disconnect() + } + }, [measure, children]) + + // Bring whatever is marked as current into view. Guarded because not every + // environment implements it — jsdom does not, and neither did older Safari. + useEffect(() => { + const current = box.current?.querySelector('[aria-current]') + if (typeof current?.scrollIntoView === 'function') { + current.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + } + }, [children]) + + const nudge = (direction) => { + const el = box.current + if (typeof el?.scrollBy === 'function') el.scrollBy({ left: direction * step, behavior: 'smooth' }) + else if (el) el.scrollLeft += direction * step + } + + const onWheel = (event) => { + const el = box.current + if (!el || event.deltaY === 0 || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return + const max = el.scrollWidth - el.clientWidth + if (max <= 0) return + // Only swallow the page's scroll while this strip can still move, so + // reaching the end hands the wheel back to the page. + const next = el.scrollLeft + event.deltaY + if (next > 0 && next < max) event.preventDefault() + el.scrollLeft = next + } + + return ( +
+ {edges.start && ( + + )} + {/* Rendered as whatever it is replacing, so a section bar stays a
+ ) +} diff --git a/frontend/src/components/ScrollStrip.test.jsx b/frontend/src/components/ScrollStrip.test.jsx new file mode 100644 index 0000000..9de80ed --- /dev/null +++ b/frontend/src/components/ScrollStrip.test.jsx @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ScrollStrip from './ScrollStrip' + +/** jsdom gives every element zero size, so the strip is told how big it is. */ +function sizeStrip({ scrollWidth, clientWidth, scrollLeft = 0 }) { + const strip = document.querySelector('.ss-strip') + Object.defineProperty(strip, 'scrollWidth', { value: scrollWidth, configurable: true }) + Object.defineProperty(strip, 'clientWidth', { value: clientWidth, configurable: true }) + strip.scrollLeft = scrollLeft + strip.scrollBy = vi.fn() + // The measurement runs off a scroll event, so its state update has to be + // flushed before anything is asserted about the arrows. + act(() => { strip.dispatchEvent(new Event('scroll')) }) + return strip +} + +const links = () => Array.from({ length: 12 }, (_, i) => Page {i}) + +describe('a strip that scrolls sideways', () => { + beforeEach(() => { vi.clearAllMocks() }) + + it('offers nothing when everything already fits', () => { + render({links()}) + sizeStrip({ scrollWidth: 400, clientWidth: 400 }) + expect(screen.queryByLabelText('Scroll right')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Scroll left')).not.toBeInTheDocument() + }) + + it('offers a way to the end when there is more than fits', () => { + render({links()}) + sizeStrip({ scrollWidth: 1200, clientWidth: 600 }) + // The old bar faded its right edge and hid the scrollbar, which said + // "there is more" and gave a mouse no way to get there. + expect(screen.getByLabelText('Scroll right')).toBeInTheDocument() + expect(screen.queryByLabelText('Scroll left')).not.toBeInTheDocument() + }) + + it('offers the way back once you have moved', () => { + render({links()}) + sizeStrip({ scrollWidth: 1200, clientWidth: 600, scrollLeft: 300 }) + expect(screen.getByLabelText('Scroll left')).toBeInTheDocument() + expect(screen.getByLabelText('Scroll right')).toBeInTheDocument() + }) + + it('stops offering the end once you are at it', () => { + render({links()}) + sizeStrip({ scrollWidth: 1200, clientWidth: 600, scrollLeft: 600 }) + expect(screen.queryByLabelText('Scroll right')).not.toBeInTheDocument() + expect(screen.getByLabelText('Scroll left')).toBeInTheDocument() + }) + + it('is not fooled by a sub-pixel remainder at the end', () => { + render({links()}) + // scrollLeft lands a hair short of max; the arrow must still switch off. + sizeStrip({ scrollWidth: 1200, clientWidth: 600, scrollLeft: 599.6 }) + expect(screen.queryByLabelText('Scroll right')).not.toBeInTheDocument() + }) + + it('moves the strip when an arrow is used', async () => { + render({links()}) + const strip = sizeStrip({ scrollWidth: 1200, clientWidth: 600 }) + await userEvent.click(screen.getByLabelText('Scroll right')) + expect(strip.scrollBy).toHaveBeenCalledWith({ left: 220, behavior: 'smooth' }) + }) + + it('keeps the arrows out of the tab order — they duplicate the links', () => { + render({links()}) + sizeStrip({ scrollWidth: 1200, clientWidth: 600 }) + expect(screen.getByLabelText('Scroll right')).toHaveAttribute('tabindex', '-1') + }) +}) diff --git a/frontend/src/index.css b/frontend/src/index.css index 3ebb2dd..eaebf1b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -143,13 +143,10 @@ body { /* The strip scrolls when the links outrun the width; the fade on the right is what says so, since a hard cut just looks like a broken layout. */ .navbar-sections-inner { position: relative; } -.nav-sections { - display: flex; align-items: center; gap: 2px; flex: 1; min-width: 0; - overflow-x: auto; scrollbar-width: none; - -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); - mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); -} -.nav-sections::-webkit-scrollbar { display: none; } +/* Layout and scrolling belong to ScrollStrip; this only styles the links. + The mask fade that used to sit here implied scrollability and gave nothing + to act on — the arrows do that job now, and a fade over one of them would + only make the arrow look broken. */ .navbar .nav-sections a { color: var(--text); font-size: 0.84rem; font-weight: 500; opacity: 0.78; padding: 7px 11px; border-radius: 7px; white-space: nowrap; @@ -706,7 +703,8 @@ body { copy of the same links competing for the same thumb. */ .nav-burger { display: flex; } .nav-logout { display: none; } - .navbar-sections .nav-sections { display: none; } + /* Hide the whole strip, wrapper and arrows, not just the link row. */ + .navbar-sections .ss-wrap { display: none; } .navbar-sections, .navbar-sections-inner, .navbar-sections:focus-within { height: 42px; } .navbar-sections.is-hidden { height: 0; } /* Mobile quiz: hide sidebar, show toggle */