Resizable right column in the Study stage; start on the chosen page without a flash
Build and Push Docker Image / build-and-push (push) Failing after 20s
Build and Push Docker Image / build-and-push (push) Failing after 20s
The Study stage's right column has a drag handle (mouse, arrow keys, double-click or Home to reset); the width is remembered per device. The app now starts on the user's chosen home page from the first render instead of showing My Studies first. Tests wait on page headings so a lazily loaded page can't be confused with hidden text from the previous one. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8c4f54e033
commit
7a010fcda8
5 files changed
+226
-20
No files matched your search
+10
-12
@@ -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;
|
||||
|
||||
+46
-6
@@ -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(<App />);
|
||||
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(<App />);
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
+14
-2
@@ -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 (
|
||||
<StudioShell stage="study" fixedHeight>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto lg:grid lg:grid-cols-[15rem_minmax(0,1fr)_28rem] lg:overflow-hidden xl:grid-cols-[16rem_minmax(0,1fr)_32rem]">
|
||||
<div ref={gridRef} style={rightPanel.width ? { '--study-right': `${rightPanel.width}px` } : undefined}
|
||||
className="flex min-h-0 flex-1 flex-col overflow-y-auto lg:grid lg:grid-cols-[15rem_minmax(0,1fr)_var(--study-right,28rem)] lg:overflow-hidden xl:grid-cols-[16rem_minmax(0,1fr)_var(--study-right,32rem)]">
|
||||
<aside className="hidden min-h-0 border-r border-[var(--r-line)] bg-[var(--r-bar)] lg:block">
|
||||
<OutlineRail project={project} selectedId={selectedChunk?.id} onSelect={select} onEditOutline={() => setCurrentPage('setup')} />
|
||||
</aside>
|
||||
@@ -114,7 +121,12 @@ export default function StudyPage() {
|
||||
)}
|
||||
</main>
|
||||
|
||||
<aside className="flex min-h-0 flex-col gap-3 border-t border-[var(--r-line)] bg-[var(--r-bar)] p-3 lg:border-l lg:border-t-0">
|
||||
<aside ref={rightPanelRef} className="relative flex min-h-0 flex-col gap-3 border-t border-[var(--r-line)] bg-[var(--r-bar)] p-3 lg:border-l lg:border-t-0">
|
||||
{/* Drag handle on the column's left edge (wide screens only) */}
|
||||
<div {...rightPanel.handleProps(rightPanelRef)}
|
||||
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>
|
||||
{selectedChunk && chapter && (
|
||||
<>
|
||||
<section aria-label="Notes" className="flex min-h-[22rem] flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] lg:min-h-0 lg:flex-[11]">
|
||||
|
||||
Reference in new issue
Block a user