diff --git a/src/App.jsx b/src/App.jsx index 3153bd7..0f39a66 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -32,10 +32,19 @@ const ReadingPlansPage = lazy(() => import('./pages/ReadingPlansPage.jsx')); // App component // --------------------------------------------------------------------------- +// The page each "Home page" choice in Settings opens. +const START_PAGES = { studies: 'home', reader: 'reader', plans: 'reading-plans' }; + +// The user's chosen home page, read straight from this device's saved preference so the app +// starts on it from the very first render (no flash of another page first). +function savedStartPage() { + try { return START_PAGES[localStorage.getItem('start-page')] ?? 'home'; } catch { return 'home'; } +} + const App = () => { const [project, setProject] = useState(null); // 'home' | 'setup' | 'study' | 'script' | 'export' | 'reader' | 'import' | 'pronunciation' | 'settings' | 'admin' | 'reading-plans' - const [currentPage, setCurrentPage] = useState('home'); + const [currentPage, setCurrentPage] = useState(savedStartPage); const [projectIndex, setProjectIndex] = useState([]); const [saveStatus, setSaveStatus] = useState(''); const saveTimerRef = useRef(null); @@ -237,7 +246,6 @@ const App = () => { // "Home": the page the user chose in Settings (My Studies, the Bible Reader, or Reading // Plans). If they're already on it, Home means My Studies, so there is always somewhere to go. - const START_PAGES = { studies: 'home', reader: 'reader', plans: 'reading-plans' }; const goHome = () => { const target = START_PAGES[reader.startPagePref] ?? 'home'; if (target === 'home' || target === currentPage) { goStudies(); return; } @@ -246,16 +254,6 @@ const App = () => { setCurrentPage(target); }; - // Open the app on the chosen home page (once, as soon as we know who is signed in). - const openedStartPageRef = useRef(false); - useEffect(() => { - if (authUser === undefined || openedStartPageRef.current) return; - openedStartPageRef.current = true; - const target = START_PAGES[reader.startPagePref] ?? 'home'; - if (target !== 'home') setCurrentPage(target); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [authUser]); - // Manual retry after a failed background sync (shown in the project header). const retrySync = async () => { if (!project) return; diff --git a/src/App.test.jsx b/src/App.test.jsx index 6f2e967..35ff732 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -1290,7 +1290,7 @@ describe('Chosen home page', () => { expect(await screen.findByText('My Studies')).toBeInTheDocument(); // from another page, Home is the chosen page await u.click(screen.getByRole('button', { name: /reading plans/i })); - await screen.findByText(/browse plans/i); + await screen.findByRole('heading', { name: /^reading plans$/i }); await u.click(screen.getByRole('button', { name: homeBtn })); expect(await screen.findByText(/Genesis 1 · BSB/)).toBeInTheDocument(); }); @@ -1299,13 +1299,13 @@ describe('Chosen home page', () => { localStorage.setItem('start-page', 'plans'); const u = userEvent.setup(); render(); - await screen.findByText(/browse plans/i); // opened on Reading Plans + await screen.findByRole('heading', { name: /^reading plans$/i }); // opened on Reading Plans await u.click(screen.getByRole('button', { name: homeBtn })); // already there -> My Studies await screen.findByText('My Studies'); await u.click(screen.getByRole('button', { name: /import session list/i })); await screen.findByRole('heading', { name: /import session list/i }); await u.click(screen.getByRole('button', { name: /bible study home/i })); - expect(await screen.findByText(/browse plans/i)).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: /^reading plans$/i })).toBeInTheDocument(); await u.click(screen.getByRole('button', { name: homeBtn })); // -> My Studies again await u.click(screen.getByRole('button', { name: /import session list/i })); await u.click(screen.getByRole('button', { name: /^studies$/i })); @@ -1316,13 +1316,13 @@ describe('Chosen home page', () => { localStorage.setItem('start-page', 'plans'); const u = userEvent.setup(); render(); - await screen.findByText(/browse plans/i); + await screen.findByRole('heading', { name: /^reading plans$/i }); await u.click(screen.getByRole('button', { name: homeBtn })); // to My Studies await u.click(screen.getAllByRole('button', { name: /new project/i })[0]); await u.click(await screen.findByRole('button', { name: /load chapter/i })); await screen.findByText(/Scripture & Chunks/i); await u.click(screen.getByRole('button', { name: homeBtn })); - expect(await screen.findByText(/browse plans/i)).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: /^reading plans$/i })).toBeInTheDocument(); }); test('Settings changes it, and it is saved for next time', async () => { @@ -1348,6 +1348,46 @@ describe('Chosen home page', () => { await u.click(within(prefs.getByRole('group', { name: 'Home page' })).getByRole('button', { name: 'Reading Plans' })); expect(localStorage.getItem('start-page')).toBe('plans'); await u.click(screen.getByRole('button', { name: homeBtn })); // Settings back -> the new home page - expect(await screen.findByText(/browse plans/i)).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: /^reading plans$/i })).toBeInTheDocument(); + }); +}); + +describe('Resizable study column', () => { + async function openStudy() { + const u = await loadChapter(); + addChunk('Paul, a servant', 'In hope of eternal'); + await screen.findByText('1 created'); + await u.click(screen.getByRole('button', { name: /begin studying/i })); + await findChunkCounter(1, 1); + return u; + } + const columns = () => screen.getByRole('separator', { name: /resize side panel/i }).closest('aside').parentElement; + + test('the handle widens and narrows the right column and the choice is remembered', async () => { + const u = await openStudy(); + const handle = screen.getByRole('separator', { name: /resize side panel/i }); + expect(columns().style.getPropertyValue('--study-right')).toBe(''); // default width + handle.focus(); + await u.keyboard('{ArrowLeft}'); + expect(columns().style.getPropertyValue('--study-right')).toBe('472px'); + expect(localStorage.getItem('study-right-width')).toBe('472'); + await u.keyboard('{Shift>}{ArrowRight}{/Shift}'); + expect(columns().style.getPropertyValue('--study-right')).toBe('376px'); + }); + + test('double-clicking the handle restores the default width', async () => { + const u = await openStudy(); + const handle = screen.getByRole('separator', { name: /resize side panel/i }); + handle.focus(); + await u.keyboard('{ArrowLeft}'); + await u.dblClick(handle); + expect(localStorage.getItem('study-right-width')).toBeNull(); + expect(columns().style.getPropertyValue('--study-right')).toBe(''); + }); + + test('a saved width is applied when the study opens', async () => { + localStorage.setItem('study-right-width', '560'); + await openStudy(); + expect(columns().style.getPropertyValue('--study-right')).toBe('560px'); }); }); diff --git a/src/hooks/useResizableWidth.js b/src/hooks/useResizableWidth.js new file mode 100644 index 0000000..9117437 --- /dev/null +++ b/src/hooks/useResizableWidth.js @@ -0,0 +1,75 @@ +// A drag-to-resize width for a side panel (the Study stage's right column). The handle owns +// pointer dragging, arrow keys and double-click reset; the width is remembered per device. +import { useCallback, useRef, useState } from 'react'; + +const STEP = 24; +const BIG_STEP = 96; + +// 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 }) { + const maxFor = () => { + const w = containerRef.current?.getBoundingClientRect().width ?? 0; + return w > 0 ? Math.max(min, Math.min(maxAbsolute, w - reserve)) : maxAbsolute; + }; + const clamp = (n) => Math.round(Math.min(maxFor(), Math.max(min, n))); + + const [width, setWidthState] = useState(() => { + try { + const n = Number(localStorage.getItem(storageKey)); + return Number.isFinite(n) && n > 0 ? Math.min(maxAbsolute, Math.max(min, Math.round(n))) : null; + } catch { return null; } + }); + const dragRef = useRef(null); + + const commit = useCallback((n) => { + setWidthState(n); + try { + if (n == null) localStorage.removeItem(storageKey); + else localStorage.setItem(storageKey, String(n)); + } catch { /* storage unavailable */ } + }, [storageKey]); + + // Width of the panel right now (state, else measured from the panel element, else a default). + const current = (panelEl) => width ?? (panelEl?.getBoundingClientRect().width || fallback); + + const handleProps = (panelRef) => ({ + role: 'separator', + 'aria-orientation': 'vertical', + 'aria-label': 'Resize side panel', + 'aria-valuemin': min, + 'aria-valuemax': maxAbsolute, + 'aria-valuenow': Math.round(current(panelRef.current)), + tabIndex: 0, + onPointerDown: (e) => { + if (e.button !== undefined && e.button !== 0) return; + e.currentTarget.setPointerCapture?.(e.pointerId); + dragRef.current = { id: e.pointerId }; + document.body.style.userSelect = 'none'; + }, + onPointerMove: (e) => { + if (!dragRef.current) return; + const right = containerRef.current?.getBoundingClientRect().right ?? window.innerWidth; + const next = clamp(right - e.clientX); + dragRef.current.width = next; + containerRef.current?.style.setProperty('--study-right', `${next}px`); // smooth: no React render per move + }, + onPointerUp: (e) => { + if (!dragRef.current) return; + e.currentTarget.releasePointerCapture?.(e.pointerId); + const done = dragRef.current.width; + dragRef.current = null; + document.body.style.userSelect = ''; + if (done != null) commit(done); + }, + onKeyDown: (e) => { + const step = e.shiftKey ? BIG_STEP : 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 === 'Home' || e.key === 'Escape') { e.preventDefault(); containerRef.current?.style.removeProperty('--study-right'); commit(null); } + }, + onDoubleClick: () => { containerRef.current?.style.removeProperty('--study-right'); commit(null); }, + title: 'Drag to resize · double-click to reset', + }); + + return { width, handleProps }; +} diff --git a/src/hooks/useResizableWidth.test.js b/src/hooks/useResizableWidth.test.js new file mode 100644 index 0000000..b47f673 --- /dev/null +++ b/src/hooks/useResizableWidth.test.js @@ -0,0 +1,81 @@ +import { renderHook, act } from '@testing-library/react'; +import { vi, test, expect, beforeEach } from 'vitest'; +import { useResizableWidth } from './useResizableWidth.js'; + +beforeEach(() => { localStorage.clear(); }); + +// A container 1400px wide whose right edge is at x=1400, and a panel currently 448px wide. +const setup = (opts = {}) => { + const style = { setProperty: vi.fn(), removeProperty: vi.fn() }; + const containerRef = { current: { getBoundingClientRect: () => ({ right: 1400, width: 1400 }), style } }; + const panelRef = { current: { getBoundingClientRect: () => ({ width: 448 }) } }; + const hook = renderHook(() => useResizableWidth({ storageKey: 'w', containerRef, ...opts })); + const handle = () => hook.result.current.handleProps(panelRef); + return { hook, handle, style }; +}; +const target = { setPointerCapture: vi.fn(), releasePointerCapture: vi.fn() }; +const down = (h) => h.handle().onPointerDown({ button: 0, pointerId: 1, currentTarget: target }); +const move = (h, clientX) => h.handle().onPointerMove({ clientX }); +const up = (h) => h.handle().onPointerUp({ pointerId: 1, currentTarget: target }); + +test('dragging sets the width from the pointer to the right edge, then remembers it', () => { + const h = setup(); + act(() => { down(h); move(h, 900); }); + expect(h.style.setProperty).toHaveBeenLastCalledWith('--study-right', '500px'); // 1400 - 900 + expect(h.hook.result.current.width).toBeNull(); // no React render per pointer move + act(() => up(h)); + expect(h.hook.result.current.width).toBe(500); + expect(localStorage.getItem('w')).toBe('500'); +}); + +test('the width is kept between a minimum and a maximum that leaves room for the passage', () => { + const h = setup(); + act(() => { down(h); move(h, 1350); }); // would be 50px + expect(h.style.setProperty).toHaveBeenLastCalledWith('--study-right', '320px'); + act(() => { move(h, 100); }); // would be 1300px; the container leaves 1400 - 600 = 800, capped at 760 + expect(h.style.setProperty).toHaveBeenLastCalledWith('--study-right', '760px'); + act(() => up(h)); + expect(h.hook.result.current.width).toBe(760); +}); + +test('a move without a press does nothing', () => { + const h = setup(); + act(() => move(h, 900)); + expect(h.style.setProperty).not.toHaveBeenCalled(); +}); + +test('arrow keys resize from the current width; shift takes bigger steps', () => { + const h = setup(); + act(() => h.handle().onKeyDown({ key: 'ArrowLeft', preventDefault() {} })); + expect(h.hook.result.current.width).toBe(472); // 448 + 24: wider + act(() => h.handle().onKeyDown({ key: 'ArrowRight', shiftKey: true, preventDefault() {} })); + expect(h.hook.result.current.width).toBe(376); // 472 - 96: narrower + expect(localStorage.getItem('w')).toBe('376'); +}); + +test('Home, Escape and double-click go back to the default width', () => { + localStorage.setItem('w', '600'); + const h = setup(); + expect(h.hook.result.current.width).toBe(600); + act(() => h.handle().onKeyDown({ key: 'Home', preventDefault() {} })); + expect(h.hook.result.current.width).toBeNull(); + expect(localStorage.getItem('w')).toBeNull(); + expect(h.style.removeProperty).toHaveBeenCalledWith('--study-right'); + act(() => h.handle().onKeyDown({ key: 'ArrowLeft', preventDefault() {} })); + act(() => h.handle().onDoubleClick()); + expect(h.hook.result.current.width).toBeNull(); +}); + +test('a remembered width is restored, kept within the limits, and junk is ignored', () => { + localStorage.setItem('w', '100'); + expect(setup().hook.result.current.width).toBe(320); + localStorage.setItem('w', '5000'); + expect(setup().hook.result.current.width).toBe(760); + localStorage.setItem('w', 'wide'); + expect(setup().hook.result.current.width).toBeNull(); +}); + +test('the handle describes itself for assistive technology', () => { + const props = setup().handle(); + expect(props).toMatchObject({ role: 'separator', 'aria-orientation': 'vertical', 'aria-label': 'Resize side panel', tabIndex: 0, 'aria-valuenow': 448 }); +}); diff --git a/src/pages/StudyPage.jsx b/src/pages/StudyPage.jsx index 9c1fb5a..6a7f963 100644 --- a/src/pages/StudyPage.jsx +++ b/src/pages/StudyPage.jsx @@ -6,6 +6,7 @@ import { useApp } from '../context/AppContext.js'; import { useCrossRefs } from '../hooks/useCrossRefs.js'; import { useStudyResources } from '../hooks/useStudyResources.js'; import { useWordStudy } from '../hooks/useWordStudy.js'; +import { useResizableWidth } from '../hooks/useResizableWidth.js'; import { formatChunkReference, getChunkVerseEntries } from '../utils/bibleUtils.jsx'; import StudioShell from '../components/study/StudioShell.jsx'; import OutlineRail from '../components/study/OutlineRail.jsx'; @@ -33,6 +34,11 @@ export default function StudyPage() { const verses = selectedChunk ? getChunkVerseEntries(project, chapterIndex, selectedChunk) : []; const globalIndex = allChunks.findIndex((c) => c.id === selectedChunk?.id); + // The right column's width can be dragged (remembered on this device) + const gridRef = useRef(null); + const rightPanelRef = useRef(null); + const rightPanel = useResizableWidth({ storageKey: 'study-right-width', containerRef: gridRef }); + const [tab, setTab] = useResourceTab(); const [openWordId, setOpenWordId] = useState(null); useEffect(() => { setOpenWordId(null); }, [selectedChunk?.id]); @@ -79,7 +85,8 @@ export default function StudyPage() { return ( -
+
@@ -114,7 +121,12 @@ export default function StudyPage() { )} -