Reader: resizable side panel

Drag the left edge of the verse side panel to resize it (arrow keys too,
Shift for bigger steps; double-click resets). The text reflows to make room,
and the width is remembered on this device. Reuses the Study page's resize
handle; the hook now takes the CSS variable to drive.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-01 16:37:50 -04:00
1 parent cc2a8cd111
commit e5812fd4ee
6 files changed
+47 -11

No files matched your search

+4 -3
View File
@@ -7,7 +7,7 @@ import { CloseIcon } from './icons.jsx';
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
export default function Sheet({ open, onClose, title, label, headerRight, modal = true, pane = false, children }) { export default function Sheet({ open, onClose, title, label, headerRight, modal = true, pane = false, panelRef, edge, children }) {
const dialogRef = useRef(null); const dialogRef = useRef(null);
const onCloseRef = useRef(onClose); const onCloseRef = useRef(onClose);
onCloseRef.current = onClose; onCloseRef.current = onClose;
@@ -41,15 +41,16 @@ export default function Sheet({ open, onClose, title, label, headerRight, modal
<> <>
{modal && <div className="fixed inset-0 z-40 bg-black/30" onClick={onClose} aria-hidden="true" />} {modal && <div className="fixed inset-0 z-40 bg-black/30" onClick={onClose} aria-hidden="true" />}
<div <div
ref={dialogRef} ref={(el) => { dialogRef.current = el; if (panelRef) panelRef.current = el; }}
role="dialog" role="dialog"
aria-modal={modal ? 'true' : undefined} aria-modal={modal ? 'true' : undefined}
aria-label={typeof title === 'string' ? title : label} aria-label={typeof title === 'string' ? title : label}
tabIndex={-1} tabIndex={-1}
className={`fixed inset-x-0 bottom-0 z-50 flex max-h-[72vh] flex-col rounded-t-2xl focus:outline-none border-t border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] font-sans ${ className={`fixed inset-x-0 bottom-0 z-50 flex max-h-[72vh] flex-col rounded-t-2xl focus:outline-none border-t border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] font-sans ${
pane ? 'lg:inset-x-auto lg:bottom-0 lg:right-0 lg:top-12 lg:max-h-none lg:w-[24rem] lg:rounded-none lg:border-l lg:border-t-0' : 'sm:left-1/2 sm:right-auto sm:w-[36rem] sm:-translate-x-1/2' pane ? 'lg:inset-x-auto lg:bottom-0 lg:right-0 lg:top-12 lg:max-h-none lg:w-[var(--reader-pane,24rem)] lg:rounded-none lg:border-l lg:border-t-0' : 'sm:left-1/2 sm:right-auto sm:w-[36rem] sm:-translate-x-1/2'
}`} }`}
> >
{edge}
<div className="mx-auto mt-2 h-1 w-9 shrink-0 rounded-full bg-[var(--r-line)] lg:hidden" /> <div className="mx-auto mt-2 h-1 w-9 shrink-0 rounded-full bg-[var(--r-line)] lg:hidden" />
<div className="flex items-center justify-between gap-2 px-4 pb-2 pt-2"> <div className="flex items-center justify-between gap-2 px-4 pb-2 pt-2">
<div className="min-w-0 truncate text-sm font-medium">{title}</div> <div className="min-w-0 truncate text-sm font-medium">{title}</div>
+2 -2
View File
@@ -142,7 +142,7 @@ function CompareTab({ book, chapter, verse, current }) {
export default function VerseSheet({ export default function VerseSheet({
verse, book, bookName, chapter, translation, words, crossRefs, crossRefsLoading, onLoadCrossRefs, verse, book, bookName, chapter, translation, words, crossRefs, crossRefsLoading, onLoadCrossRefs,
bookmarkColor, onToggleBookmark, onCycleColor, onJump, onClose, request, bookmarkColor, onToggleBookmark, onCycleColor, onJump, onClose, request,
verseHighlights = [], colorLabels = {}, onHighlightVerse, onClearVerseHighlights, verseHighlights = [], colorLabels = {}, onHighlightVerse, onClearVerseHighlights, panelRef, resizeHandle,
}) { }) {
const [tab, setTab] = useState('commentary'); const [tab, setTab] = useState('commentary');
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -175,7 +175,7 @@ export default function VerseSheet({
return ( return (
<Sheet <Sheet
open modal={false} pane onClose={onClose} open modal={false} pane onClose={onClose} panelRef={panelRef} edge={resizeHandle}
title={`${bookName} ${chapter}:${verse.number}`} title={`${bookName} ${chapter}:${verse.number}`}
headerRight={( headerRight={(
<> <>
+5 -4
View File
@@ -6,7 +6,8 @@ const STEP = 24;
const BIG_STEP = 96; const BIG_STEP = 96;
// containerRef: the element the panel sits at the right edge of (for measuring and clamping). // containerRef: the element the panel sits at the right edge of (for measuring and clamping).
export function useResizableWidth({ storageKey, min = 320, maxAbsolute = 760, fallback = 448, reserve = 600, containerRef }) { // cssVar: the custom property on the container that carries the width while dragging.
export function useResizableWidth({ storageKey, min = 320, maxAbsolute = 760, fallback = 448, reserve = 600, containerRef, cssVar = '--study-right' }) {
const maxFor = () => { const maxFor = () => {
const w = containerRef.current?.getBoundingClientRect().width ?? 0; const w = containerRef.current?.getBoundingClientRect().width ?? 0;
return w > 0 ? Math.max(min, Math.min(maxAbsolute, w - reserve)) : maxAbsolute; return w > 0 ? Math.max(min, Math.min(maxAbsolute, w - reserve)) : maxAbsolute;
@@ -51,7 +52,7 @@ export function useResizableWidth({ storageKey, min = 320, maxAbsolute = 760, fa
const right = containerRef.current?.getBoundingClientRect().right ?? window.innerWidth; const right = containerRef.current?.getBoundingClientRect().right ?? window.innerWidth;
const next = clamp(right - e.clientX); const next = clamp(right - e.clientX);
dragRef.current.width = next; dragRef.current.width = next;
containerRef.current?.style.setProperty('--study-right', `${next}px`); // smooth: no React render per move containerRef.current?.style.setProperty(cssVar, `${next}px`); // smooth: no React render per move
}, },
onPointerUp: (e) => { onPointerUp: (e) => {
if (!dragRef.current) return; if (!dragRef.current) return;
@@ -65,9 +66,9 @@ export function useResizableWidth({ storageKey, min = 320, maxAbsolute = 760, fa
const step = e.shiftKey ? BIG_STEP : STEP; const step = e.shiftKey ? BIG_STEP : STEP;
if (e.key === 'ArrowLeft') { e.preventDefault(); commit(clamp(current(panelRef.current) + step)); } if (e.key === 'ArrowLeft') { e.preventDefault(); commit(clamp(current(panelRef.current) + step)); }
else if (e.key === 'ArrowRight') { e.preventDefault(); commit(clamp(current(panelRef.current) - step)); } else if (e.key === 'ArrowRight') { e.preventDefault(); commit(clamp(current(panelRef.current) - step)); }
else if (e.key === 'Home' || e.key === 'Escape') { e.preventDefault(); containerRef.current?.style.removeProperty('--study-right'); commit(null); } else if (e.key === 'Home' || e.key === 'Escape') { e.preventDefault(); containerRef.current?.style.removeProperty(cssVar); commit(null); }
}, },
onDoubleClick: () => { containerRef.current?.style.removeProperty('--study-right'); commit(null); }, onDoubleClick: () => { containerRef.current?.style.removeProperty(cssVar); commit(null); },
title: 'Drag to resize · double-click to reset', title: 'Drag to resize · double-click to reset',
}); });
+9
View File
@@ -79,3 +79,12 @@ test('the handle describes itself for assistive technology', () => {
const props = setup().handle(); const props = setup().handle();
expect(props).toMatchObject({ role: 'separator', 'aria-orientation': 'vertical', 'aria-label': 'Resize side panel', tabIndex: 0, 'aria-valuenow': 448 }); expect(props).toMatchObject({ role: 'separator', 'aria-orientation': 'vertical', 'aria-label': 'Resize side panel', tabIndex: 0, 'aria-valuenow': 448 });
}); });
test('a different panel can name its own CSS variable', () => {
const h = setup({ cssVar: '--reader-pane' });
act(() => { down(h); move(h, 1000); });
expect(h.style.setProperty).toHaveBeenLastCalledWith('--reader-pane', '400px');
act(() => up(h));
act(() => { h.handle().onDoubleClick(); });
expect(h.style.removeProperty).toHaveBeenLastCalledWith('--reader-pane');
});
+15 -2
View File
@@ -6,6 +6,7 @@ import { bookOptions } from '../utils/bibleUtils.jsx';
import { getPlan, getPlanSchedule, findDayIndexForRef, formatDayReadings, nextUnreadDayIndex, computeStreak, nearestReadingDay } from '../utils/readingPlans.js'; import { getPlan, getPlanSchedule, findDayIndexForRef, formatDayReadings, nextUnreadDayIndex, computeStreak, nearestReadingDay } from '../utils/readingPlans.js';
import DrawCanvas from './DrawCanvas.jsx'; import DrawCanvas from './DrawCanvas.jsx';
import { READER_TRANSLATIONS } from '../utils/bibleText.js'; import { READER_TRANSLATIONS } from '../utils/bibleText.js';
import { useResizableWidth } from '../hooks/useResizableWidth.js';
import { useParallelChapter } from '../hooks/useParallelChapter.js'; import { useParallelChapter } from '../hooks/useParallelChapter.js';
import { useOfflineSave, useOnline } from '../hooks/useOfflineSave.js'; import { useOfflineSave, useOnline } from '../hooks/useOfflineSave.js';
import { useBibleSearch } from '../hooks/useBibleSearch.js'; import { useBibleSearch } from '../hooks/useBibleSearch.js';
@@ -108,6 +109,10 @@ export default function ReaderPage() {
const [selectionPicker, setSelectionPicker] = useState(null); const [selectionPicker, setSelectionPicker] = useState(null);
const [xrefRequest, setXrefRequest] = useState(null); // a cross-reference tapped in the text: { verse, ref, n } const [xrefRequest, setXrefRequest] = useState(null); // a cross-reference tapped in the text: { verse, ref, n }
const swipeTouchRef = useRef(null); const swipeTouchRef = useRef(null);
// The side panel's width: drag its left edge (remembered on this device)
const rootRef = useRef(null);
const paneRef = useRef(null);
const pane = useResizableWidth({ storageKey: 'reader-pane-width', containerRef: rootRef, min: 320, maxAbsolute: 800, fallback: 384, reserve: 460, cssVar: '--reader-pane' });
const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? NO_STROKES; const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? NO_STROKES;
@@ -412,7 +417,7 @@ export default function ReaderPage() {
const paneOpen = !!selectedVerse && !readerDrawMode; const paneOpen = !!selectedVerse && !readerDrawMode;
return ( return (
<div className="min-h-dvh bg-[var(--r-bg)] text-[var(--r-text)]" style={themeVars(readerTheme)} <div ref={rootRef} className="min-h-dvh bg-[var(--r-bg)] text-[var(--r-text)]" style={{ ...themeVars(readerTheme), ...(pane.width ? { '--reader-pane': `${pane.width}px` } : {}) }}
onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd}> onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd}>
{chromeVisible && ( {chromeVisible && (
<TopBar <TopBar
@@ -452,7 +457,7 @@ export default function ReaderPage() {
</p> </p>
)} )}
<div className={paneOpen ? 'lg:pr-[24rem]' : ''}> <div className={paneOpen ? 'lg:pr-[var(--reader-pane,24rem)]' : ''}>
{!readerDrawMode && ( {!readerDrawMode && (
<ReaderText <ReaderText
title={`${readerBook?.name ?? ''} ${readerChapter}`} title={`${readerBook?.name ?? ''} ${readerChapter}`}
@@ -547,6 +552,14 @@ export default function ReaderPage() {
onJump={jump} onJump={jump}
onClose={() => setReaderSelectedVerse(null)} onClose={() => setReaderSelectedVerse(null)}
request={xrefRequest} request={xrefRequest}
panelRef={paneRef}
resizeHandle={(
// Drag this edge to resize (arrow keys too; double-click resets). Wide screens only.
<div {...pane.handleProps(paneRef)}
className="group absolute -left-1.5 top-0 z-20 hidden h-full w-3 cursor-col-resize touch-none select-none focus:outline-none lg:block">
<span className="mx-auto block h-full w-px bg-transparent transition group-hover:w-0.5 group-hover:bg-[var(--r-accent)] group-focus-visible:w-0.5 group-focus-visible:bg-[var(--r-accent)]" />
</div>
)}
verseHighlights={verseHighlights} colorLabels={readerColorLabels ?? {}} verseHighlights={verseHighlights} colorLabels={readerColorLabels ?? {}}
onHighlightVerse={highlightVerse} onClearVerseHighlights={clearVerseHighlights} onHighlightVerse={highlightVerse} onClearVerseHighlights={clearVerseHighlights}
/> />
+12
View File
@@ -142,6 +142,18 @@ test('the H key opens the highlights', () => {
expect(screen.getByRole('dialog', { name: /bookmarks & highlights/i })).toBeTruthy(); expect(screen.getByRole('dialog', { name: /bookmarks & highlights/i })).toBeTruthy();
}); });
test('the side panel has a drag handle to resize it, and no handle when no verse is open', () => {
const saved = { ...ctx };
render(<ReaderPage />);
expect(screen.queryByRole('separator', { name: /resize side panel/i })).toBeNull();
Object.assign(ctx, { readerSelectedVerse: 1 });
render(<ReaderPage />);
const handle = screen.getByRole('separator', { name: /resize side panel/i });
expect(handle).toHaveAttribute('aria-orientation', 'vertical');
expect(handle).toHaveAttribute('tabindex', '0'); // reachable by keyboard (arrow keys resize)
Object.assign(ctx, saved);
});
test('tapping a verse selects it', () => { test('tapping a verse selects it', () => {
render(<ReaderPage />); render(<ReaderPage />);
fireEvent.click(screen.getByText(/This man came to Jesus/)); fireEvent.click(screen.getByText(/This man came to Jesus/));