From 1d55b7699878c315d7efe5755a6f49f6074225cb Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 08:13:24 +0200 Subject: [PATCH] feat: browse the library as columns rather than an expanding tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tree you expand in place grows downwards, so opening a branch six deep pushes everything under it off the screen and the thing you were looking at ends up somewhere you have to hunt for. Columns grow sideways instead: each level is its own list, the branch you opened stays lit where you left it, and the path you took is readable straight across the headings. The strip scrolls so the column just opened is at the right edge with the one before it still beside it — two levels of context, and the ancestors clipped off the left with their headings legible, so going back is a click on something visible rather than a Back button. The strip is the scroller, not the page, so descending never moves the page under the reader. Not wired into a page yet; the reading library is being rebuilt alongside it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- frontend/src/components/LibraryColumns.css | 84 +++++++++++++++ frontend/src/components/LibraryColumns.jsx | 100 ++++++++++++++++++ .../src/components/LibraryColumns.test.jsx | 59 +++++++++++ 3 files changed, 243 insertions(+) create mode 100644 frontend/src/components/LibraryColumns.css create mode 100644 frontend/src/components/LibraryColumns.jsx create mode 100644 frontend/src/components/LibraryColumns.test.jsx diff --git a/frontend/src/components/LibraryColumns.css b/frontend/src/components/LibraryColumns.css new file mode 100644 index 0000000..9f634ca --- /dev/null +++ b/frontend/src/components/LibraryColumns.css @@ -0,0 +1,84 @@ +/* ── The library, as columns ────────────────────────────────────────── + One strip, scrolled sideways, with a column per level. The strip is the + scroller rather than the page, so descending a tree never moves the page + under the reader — only the columns travel. */ +.lib { + display: flex; + align-items: flex-start; + gap: 26px; + overflow-x: auto; + overflow-y: hidden; + padding: 4px 4px 20px; + /* Each column comes to rest at the left edge when the strip is dragged, so + a flick lands on a column rather than between two. */ + scroll-snap-type: x proximity; + scrollbar-width: thin; +} +.lib-col { + flex: 0 0 min(330px, 78vw); + min-width: 0; + scroll-snap-align: start; +} +.lib-col-head { + margin: 0 0 14px; + font-size: 1.35rem; + font-weight: 700; + line-height: 1.25; + color: var(--text); + overflow-wrap: anywhere; +} + +/* The rows of one level sit on a card; the level you are inside is the one + with a card under it, which is what separates "where I am" from "where I + came from" without a second colour. */ +.lib-card { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; +} +.lib-row { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 13px 14px; + font: inherit; + font-size: 0.92rem; + text-align: left; + color: var(--text); + background: none; + border: 0; + border-bottom: 1px solid var(--border); + cursor: pointer; +} +.lib-row:last-child { border-bottom: 0; } +.lib-row:hover { background: var(--bg); } +/* The branch you opened stays lit, so the path you took is readable across + the whole strip rather than only in the column you are looking at. */ +.lib-row.is-open { background: var(--bg); font-weight: 600; } + +.lib-icon { flex: none; font-size: 1rem; line-height: 1; color: var(--primary); } +.lib-icon.is-leaf { color: var(--text-subtle); } +.lib-label { flex: 1; min-width: 0; line-height: 1.4; overflow-wrap: anywhere; } +.lib-chev { flex: none; color: var(--text-subtle); font-size: 1.1rem; line-height: 1; } +.lib-row.is-open .lib-chev { color: var(--primary); } + +.lib-empty { + margin: 0; + padding: 16px 14px; + font-size: 0.86rem; + color: var(--text-muted); +} + +@media (prefers-reduced-motion: reduce) { + .lib { scroll-behavior: auto; } +} + +@media (max-width: 640px) { + /* One column at a time, and the snap becomes mandatory: a half-column on a + phone is a column you cannot read beside one you cannot finish. */ + .lib { gap: 16px; scroll-snap-type: x mandatory; } + .lib-col { flex-basis: 88vw; } + .lib-col-head { font-size: 1.1rem; } +} diff --git a/frontend/src/components/LibraryColumns.jsx b/frontend/src/components/LibraryColumns.jsx new file mode 100644 index 0000000..9c0aa32 --- /dev/null +++ b/frontend/src/components/LibraryColumns.jsx @@ -0,0 +1,100 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import './LibraryColumns.css' + +/** + * Browsing a tree as a row of columns rather than a tree you expand in place. + * + * An expanding tree grows downwards, so opening a branch six deep pushes + * everything below it off the screen and the thing you are looking at ends up + * somewhere you have to hunt for. Columns grow sideways instead: each level is + * its own list, the row you opened stays lit where you left it, and the path + * you took is readable straight across the headings. + * + * The strip scrolls so that the column you have just opened is at the right + * edge and the one before it is still beside it — two levels of context, and + * the ancestors clipped off to the left with their headings still legible, so + * going back is a click on something visible rather than a Back button. + * + * `nodes` is the flat list — every row with an `id`, a `name` and a + * `parent_id` — because that is what the API hands over, and building the + * columns here means the tree is never assembled twice. + */ +export default function LibraryColumns({ + nodes = [], + leaves = {}, + onOpenLeaf, + emptyLabel = 'Nothing filed here yet.', + rootLabel = 'Library', +}) { + // The chain of opened branches, outermost first. The columns are derived + // from it rather than stored beside it: two records of one thing is how a + // heading ends up naming a column that is no longer there. + const [path, setPath] = useState([]) + const strip = useRef(null) + + const childrenOf = useCallback( + (parentId) => nodes + .filter(node => (node.parent_id ?? null) === parentId) + .sort((a, b) => a.name.localeCompare(b.name)), + [nodes], + ) + + const columns = [{ id: null, name: rootLabel, rows: childrenOf(null) }] + for (const node of path) columns.push({ ...node, rows: childrenOf(node.id) }) + + // Layout, not effect: the scroll must land with the paint, or the new column + // appears at the right edge and then jumps. + useLayoutEffect(() => { + const el = strip.current + if (el) el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' }) + }, [path.length]) + + useEffect(() => { + const onKey = (event) => { + if (event.key === 'Escape' && path.length) setPath(p => p.slice(0, -1)) + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [path.length]) + + const open = (depth, node) => setPath(p => [...p.slice(0, depth), node]) + + return ( +
+ {columns.map((column, depth) => ( +
+

{column.name}

+
+ {column.rows.length === 0 && (leaves[column.id] || []).length === 0 && ( +

{emptyLabel}

+ )} + {/* Branches first, then what is filed at this level. A folder and a + page in one list, shuffled together alphabetically, makes the + reader check an icon on every row to know what a click does. */} + {column.rows.map(row => { + const isOpen = path[depth]?.id === row.id + return ( + + ) + })} + {(leaves[column.id] || []).map(leaf => ( + + ))} +
+
+ ))} +
+ ) +} diff --git a/frontend/src/components/LibraryColumns.test.jsx b/frontend/src/components/LibraryColumns.test.jsx new file mode 100644 index 0000000..7a5b0e2 --- /dev/null +++ b/frontend/src/components/LibraryColumns.test.jsx @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import LibraryColumns from './LibraryColumns' + +const NODES = [ + { id: 1, name: 'Cardiology', parent_id: null }, + { id: 2, name: 'Neonatology', parent_id: null }, + { id: 3, name: 'Congenital Heart Disease', parent_id: 1 }, + { id: 4, name: 'Cyanotic Heart Disease', parent_id: 3 }, +] + +// jsdom has no layout, so scrollTo is not implemented on an element. +beforeEach(() => { Element.prototype.scrollTo = vi.fn() }) + +const column = (name) => screen.getByRole('region', { name: new RegExp(`^${name},`) }) + +describe('the library as columns', () => { + it('opens a column per level rather than pushing the page downwards', async () => { + render() + expect(within(column('Reading')).getByText('Cardiology')).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: /Cardiology/ })) + expect(within(column('Cardiology')).getByText('Congenital Heart Disease')).toBeInTheDocument() + // The level you came from is still there to go back to. + expect(column('Reading')).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: /Congenital Heart Disease/ })) + expect(within(column('Congenital Heart Disease')).getByText('Cyanotic Heart Disease')).toBeInTheDocument() + }) + + it('keeps the branch you opened lit, so the path is readable across the strip', async () => { + render() + const branch = screen.getByRole('button', { name: /Cardiology/ }) + expect(branch).toHaveAttribute('aria-expanded', 'false') + await userEvent.click(branch) + expect(screen.getByRole('button', { name: /Cardiology/ })).toHaveAttribute('aria-expanded', 'true') + }) + + it('replaces the columns beyond the level you go back to', async () => { + render() + await userEvent.click(screen.getByRole('button', { name: /Cardiology/ })) + await userEvent.click(screen.getByRole('button', { name: /Congenital Heart Disease/ })) + // Choosing a different branch at the top must not leave the old descent + // hanging off the right-hand end of the strip. + await userEvent.click(screen.getByRole('button', { name: /Neonatology/ })) + expect(screen.queryByRole('region', { name: /^Congenital Heart Disease,/ })).toBeNull() + expect(column('Neonatology')).toBeInTheDocument() + }) + + it('hands a leaf over rather than opening a column for it', async () => { + const onOpenLeaf = vi.fn() + render() + await userEvent.click(screen.getByRole('button', { name: /Cardiology/ })) + await userEvent.click(screen.getByRole('button', { name: 'Kawasaki disease' })) + expect(onOpenLeaf).toHaveBeenCalledWith({ id: 9, title: 'Kawasaki disease' }) + }) +})