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' }) + }) +})