Study page: chunk list as a slide-over card, notes and resources as tabs in one column
Build and Push Docker Image / build-and-push (push) Successful in 4m57s

- Chunks: a tab on the left edge opens a card over the passage; choosing a
  chunk, Esc, or clicking the passage closes it (state remembered)
- Right column: Notes is the first tab, followed by the resource tabs;
  the separate notes and resources boxes and their fold state are gone
- Column can be dragged wider (up to 960px)
- Pop-out icon opens the whole column in a large window (PopOut)
- Tests updated for the tabbed layout; new tests for the chunk card and pop-out

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-27 16:38:26 -04:00
1 parent ed22967d16
commit bbbb581af1
6 files changed
+158 -88

No files matched your search

+1 -1
View File
@@ -115,7 +115,7 @@ describe('Search palette (⌘K)', () => {
await user.type(await paletteBox(), 'abounds');
const hit = await screen.findByRole('option', { name: /Titus 1:1/ });
await user.click(hit);
expect(await screen.findByText(/Your notes/i)).toBeInTheDocument();
expect(await screen.findByRole('textbox', { name: 'Observe' })).toBeInTheDocument();
});
test('Escape closes it', async () => {
+21
View File
@@ -0,0 +1,21 @@
// A section of the Study page lifted into a large window over the page, so there's room to read commentary or
// write notes without the column's cramped width. Esc, the backdrop or Close puts it back.
import { useEffect, useRef } from 'react';
export default function PopOut({ title, onClose, children }) {
const panel = useRef(null);
useEffect(() => { panel.current?.focus(); }, []);
return (
<div className="fixed inset-0 z-[70]">
<div className="absolute inset-0 bg-black/40" onClick={onClose} aria-hidden="true" />
<div ref={panel} role="dialog" aria-modal="true" aria-label={title} tabIndex={-1}
className="absolute inset-2 flex flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] shadow-2xl focus:outline-none sm:inset-4 lg:inset-x-[7vw] lg:inset-y-5 xl:inset-x-[12vw]">
<div className="flex shrink-0 items-center justify-between border-b border-[var(--r-line)] px-4 py-2.5">
<h2 className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{title}</h2>
<button type="button" onClick={onClose} className="rounded-lg border border-[var(--r-line)] px-3 py-1 text-xs hover:bg-black/5">Close (Esc)</button>
</div>
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
</div>
</div>
);
}
+8 -5
View File
@@ -19,9 +19,11 @@ export const RESOURCE_TABS = [
{ id: 'aids', label: 'Study aids', needsIq: true }, // shown only when the server has an IQ Bible key
];
export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex, project, updateChunk, updateProject, notify, resources, wordStudy, crossRefs, openWordId, setOpenWordId }) {
// leadTab: { id, label, node } shown as the first tab, in place of the resources, when it is the selected one (the Study page
// puts the notes there, so notes and resources share one column).
export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex, project, updateChunk, updateProject, notify, resources, wordStudy, crossRefs, openWordId, setOpenWordId, leadTab }) {
const iq = useIqAvailable();
const tabs = RESOURCE_TABS.filter((t) => !t.needsIq || iq);
const tabs = [...(leadTab ? [leadTab] : []), ...RESOURCE_TABS.filter((t) => !t.needsIq || iq)];
if (tab === 'aids' && !iq) tab = 'commentary';
// Commentary-type tabs wait for your own Observe notes (per chunk, until you choose to look anyway).
const [unlocked, setUnlocked] = useState(() => new Set());
@@ -37,7 +39,7 @@ export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex
const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length, readings: (project?.readingList ?? []).filter((i) => !i.done).length };
return (
<div className="flex min-h-0 flex-1 flex-col">
<div role="tablist" aria-label="Study resources" className="flex shrink-0 flex-wrap gap-1.5 border-b border-[var(--r-line)] px-3 py-2.5">
<div role="tablist" aria-label="Study resources" className={`flex shrink-0 flex-wrap gap-1.5 border-b border-[var(--r-line)] px-3 py-2.5 ${leadTab ? 'sm:pr-11' : ''}`}>
{tabs.map((t) => (
<button key={t.id} type="button" role="tab" aria-selected={tab === t.id} onClick={() => setTab(t.id)}
className={`rounded-full border px-3 py-1 text-xs ${tab === t.id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
@@ -46,6 +48,7 @@ export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex
))}
</div>
<div role="tabpanel" className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{leadTab && tab === leadTab.id && leadTab.node}
{held && (
<div role="note" className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4 text-sm">
<p className="font-medium">Observe first</p>
@@ -68,8 +71,8 @@ export function useResourceTab() {
const [tab, setTabState] = useState(() => {
try {
const saved = localStorage.getItem('studyResourceTab');
return RESOURCE_TABS.some((t) => t.id === saved) ? saved : 'commentary';
} catch { return 'commentary'; }
return saved === 'notes' || RESOURCE_TABS.some((t) => t.id === saved) ? saved : 'notes';
} catch { return 'notes'; }
});
const setTab = (id) => {
setTabState(id);
+73 -64
View File
@@ -1,6 +1,6 @@
// The Study stage: the chunk outline on the left, the passage and your
// Observe / Interpret / Apply notes in the middle, and commentary, word study,
// cross-references and people & places on the right — all for the selected chunk.
// The Study stage: the passage in the middle, with the chunk list as a card that slides over it from the left edge,
// and one adjustable column on the right where Notes (Observe / Interpret / Apply) and the resources (commentary, word
// study, cross-references, people & places) are tabs — all for the selected chunk.
import { useEffect, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { useCrossRefs } from '../hooks/useCrossRefs.js';
@@ -12,6 +12,7 @@ import StudioShell from '../components/study/StudioShell.jsx';
import OutlineRail from '../components/study/OutlineRail.jsx';
import ScripturePane from '../components/study/ScripturePane.jsx';
import NotesPane from '../components/study/NotesPane.jsx';
import PopOut from '../components/study/PopOut.jsx';
import { commentAuthor } from '../utils/chunkComments.js';
import ResourcePane, { useResourceTab } from '../components/study/ResourcePane.jsx';
import CompareSheet from '../components/study/CompareSheet.jsx';
@@ -43,30 +44,16 @@ export default function StudyPage() {
// 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 rightPanel = useResizableWidth({ storageKey: 'study-right-width', containerRef: gridRef, maxAbsolute: 960, reserve: 480 });
const [tab, setTab] = useResourceTab();
const [openWordId, setOpenWordId] = useState(null);
const [comparing, setComparing] = useState(false);
// Either half of the right column can be folded away so the other fills it (remembered on this device).
const [folded, setFolded] = useState(() => {
try { const f = JSON.parse(localStorage.getItem('study-folded')); return { notes: !!f?.notes, resources: !!f?.resources && !f?.notes }; } catch { return { notes: false, resources: false }; }
});
const [popped, setPopped] = useState(false); // the notes-and-resources column lifted into a large window
const [focusRequest, setFocusRequest] = useState(null);
const showHalf = (which) => setFolded((cur) => {
if (!cur[which]) return cur;
const next = { ...cur, [which]: false };
try { localStorage.setItem('study-folded', JSON.stringify(next)); } catch { /* storage unavailable */ }
return next;
});
const toggleFold = (which) => setFolded((cur) => {
const other = which === 'notes' ? 'resources' : 'notes';
// folding the last open half opens the other one instead, so the column is never empty
const next = { ...cur, [which]: !cur[which] };
if (next[which] && next[other]) next[other] = false;
try { localStorage.setItem('study-folded', JSON.stringify(next)); } catch { /* storage unavailable */ }
return next;
});
// The chunk list is a card that slides over the passage (remembered on this device).
const [chunksOpen, setChunksOpenState] = useState(() => { try { return localStorage.getItem('study-chunks-open') === 'on'; } catch { return false; } });
const setChunksOpen = (open) => { setChunksOpenState(open); try { localStorage.setItem('study-chunks-open', open ? 'on' : 'off'); } catch { /* storage unavailable */ } };
useEffect(() => { setOpenWordId(null); }, [selectedChunk?.id]);
const resources = useStudyResources({
@@ -77,9 +64,10 @@ export default function StudyPage() {
const crossRefs = useCrossRefs({ project, allChunks, selectedChunk, updateChunk, notify });
const wordStudy = useWordStudy({ project, allChunks, updateProject, notify });
const select = (id) => updateProject((current) => ({ ...current, selectedChunkId: id }));
const goPrev = () => { if (globalIndex > 0) select(allChunks[globalIndex - 1].id); };
const goNext = () => { if (globalIndex >= 0 && globalIndex < allChunks.length - 1) select(allChunks[globalIndex + 1].id); };
const select = (id) => { updateProject((current) => ({ ...current, selectedChunkId: id })); setChunksOpen(false); };
const step = (to) => updateProject((current) => ({ ...current, selectedChunkId: to })); // prev/next keep the card as it is
const goPrev = () => { if (globalIndex > 0) step(allChunks[globalIndex - 1].id); };
const goNext = () => { if (globalIndex >= 0 && globalIndex < allChunks.length - 1) step(allChunks[globalIndex + 1].id); };
// Arrow keys move between chunks; Escape closes the word picker
useEffect(() => {
@@ -87,15 +75,17 @@ export default function StudyPage() {
const tag = event.target?.tagName;
const editable = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || event.target?.isContentEditable;
if (event.key === 'Escape') {
if (popped && !wordStudy.suggestModal) { setPopped(false); return; }
if (chunksOpen && !wordStudy.suggestModal && !editable) { setChunksOpen(false); return; }
if (wordStudy.suggestModal) { wordStudy.setSuggestModal(null); wordStudy.setSuggestSelection(new Set()); }
else if (editable && !comparing) event.target.blur(); // leave a note box and go back to keyboard moves
return;
}
if (editable || wordStudy.suggestModal || comparing || event.metaKey || event.ctrlKey || event.altKey) return;
if (editable || popped || wordStudy.suggestModal || comparing || event.metaKey || event.ctrlKey || event.altKey) return;
if (event.key === 'ArrowLeft') { event.preventDefault(); goPrev(); }
else if (event.key === 'ArrowRight') { event.preventDefault(); goNext(); }
else if (NOTE_KEYS[event.key]) { event.preventDefault(); showHalf('notes'); setFocusRequest({ field: NOTE_KEYS[event.key], n: Date.now() }); }
else if (TAB_KEYS[event.key.toLowerCase()]) { event.preventDefault(); showHalf('resources'); setTab(TAB_KEYS[event.key.toLowerCase()]); }
else if (NOTE_KEYS[event.key]) { event.preventDefault(); setTab('notes'); setFocusRequest({ field: NOTE_KEYS[event.key], n: Date.now() }); }
else if (TAB_KEYS[event.key.toLowerCase()]) { event.preventDefault(); setTab(TAB_KEYS[event.key.toLowerCase()]); }
else if (event.key.toLowerCase() === 'v') { event.preventDefault(); setComparing(true); }
};
window.addEventListener('keydown', onKey);
@@ -113,15 +103,40 @@ export default function StudyPage() {
if (id) { setTab('words'); setOpenWordId(id); }
};
const notesPane = selectedChunk && chapter && (
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} templateId={project?.templateDef ?? project?.templateId} saveTemplate={saveUserTemplate} notify={notify} focusRequest={focusRequest}
author={commentAuthor(authUser)} bookAbbrev={project?.chapters?.[chapterIndex]?.bookAbbrev} bookName={project?.chapters?.[chapterIndex]?.book}
onPray={() => startPrayerFor(formatChunkReference(project, chapterIndex, selectedChunk, '–'))} />
);
// Notes and the resources share one column as tabs; Notes is the first.
const workPane = selectedChunk && chapter && (
<ResourcePane
tab={tab} setTab={setTab} chunk={selectedChunk} chapter={chapter} chapterIndex={chapterIndex} project={project}
updateChunk={updateChunk} updateProject={updateProject} notify={notify}
resources={resources} wordStudy={wordStudy} crossRefs={crossRefs}
openWordId={openWordId} setOpenWordId={setOpenWordId}
leadTab={{ id: 'notes', label: 'Notes', node: notesPane }}
/>
);
const chunkPosition = selectedChunk ? `${globalIndex + 1}/${allChunks.length}` : '';
return (
<StudioShell stage="study" fixedHeight>
<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">
className="relative flex min-h-0 flex-1 flex-col overflow-y-auto lg:grid lg:grid-cols-[2rem_minmax(0,1fr)_var(--study-right,28rem)] lg:overflow-hidden xl:grid-cols-[2rem_minmax(0,1fr)_var(--study-right,32rem)]">
{/* The chunk list: a tab on the left edge, and a card that slides over the passage (wide screens) */}
<div className="hidden min-h-0 items-start justify-center border-r border-[var(--r-line)] bg-[var(--r-bar)] lg:flex">
<button type="button" onClick={() => setChunksOpen(!chunksOpen)} aria-expanded={chunksOpen} aria-controls="study-chunks-card" title="Show or hide the chunk list"
className="mt-3 rounded-lg px-1 py-3 text-[11px] uppercase tracking-wide text-[var(--r-sub)] hover:bg-black/5 hover:text-[var(--r-text)] [writing-mode:vertical-rl]">
Chunks{chunkPosition ? ` · ${chunkPosition}` : ''}
</button>
</div>
<div id="study-chunks-card" inert={!chunksOpen}
className={`absolute bottom-0 left-8 top-0 z-20 hidden w-72 max-w-[40%] border-r border-[var(--r-line)] bg-[var(--r-bar)] shadow-xl transition-[transform,visibility] duration-200 lg:block ${chunksOpen ? 'visible translate-x-0' : 'invisible -translate-x-full'}`}>
<OutlineRail project={project} selectedId={selectedChunk?.id} onSelect={select} onEditOutline={() => setCurrentPage('setup')} />
</aside>
</div>
<main className="min-h-0 lg:overflow-y-auto">
<main className="min-h-0 lg:overflow-y-auto" onMouseDown={() => { if (chunksOpen) setChunksOpen(false); }}>
{selectedChunk && chapter ? (
<div className="mx-auto max-w-3xl space-y-4 px-5 py-6">
<div className="flex items-start justify-between gap-4">
@@ -154,48 +169,24 @@ export default function StudyPage() {
)}
</main>
<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">
<aside ref={rightPanelRef} aria-label="Notes and resources" className="relative flex min-h-[32rem] flex-col border-t border-[var(--r-line)] bg-[var(--r-bar)] p-3 lg:min-h-0 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 flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] ${folded.notes ? 'shrink-0' : 'min-h-[22rem] lg:min-h-0 lg:flex-[11]'}`}>
<h2 className="shrink-0">
<button type="button" onClick={() => toggleFold('notes')} aria-expanded={!folded.notes}
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-[11px] uppercase tracking-wide text-[var(--r-sub)] hover:bg-black/5">
Your notes<span aria-hidden="true">{folded.notes ? '▸' : '▾'}</span>
</button>
</h2>
<div className={`min-h-0 flex-1 overflow-y-auto border-t border-[var(--r-line)] p-3 ${folded.notes ? 'hidden' : ''}`}>
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} templateId={project?.templateDef ?? project?.templateId} saveTemplate={saveUserTemplate} notify={notify} focusRequest={focusRequest}
author={commentAuthor(authUser)} bookAbbrev={project?.chapters?.[chapterIndex]?.bookAbbrev} bookName={project?.chapters?.[chapterIndex]?.book}
onPray={() => startPrayerFor(formatChunkReference(project, chapterIndex, selectedChunk, '–'))} />
</div>
</section>
<section aria-label="Resources" className={`flex flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] ${folded.resources ? 'shrink-0' : 'min-h-[24rem] lg:min-h-0 lg:flex-[10]'}`}>
<h2 className="shrink-0">
<button type="button" onClick={() => toggleFold('resources')} aria-expanded={!folded.resources}
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-[11px] uppercase tracking-wide text-[var(--r-sub)] hover:bg-black/5">
Commentary &amp; resources<span aria-hidden="true">{folded.resources ? '▸' : '▾'}</span>
</button>
</h2>
<div className={`flex min-h-0 flex-1 flex-col border-t border-[var(--r-line)] ${folded.resources ? 'hidden' : ''}`}>
<ResourcePane
tab={tab} setTab={setTab} chunk={selectedChunk} chapter={chapter} chapterIndex={chapterIndex} project={project}
updateChunk={updateChunk} updateProject={updateProject} notify={notify}
resources={resources} wordStudy={wordStudy} crossRefs={crossRefs}
openWordId={openWordId} setOpenWordId={setOpenWordId}
/>
</div>
</section>
</>
<section aria-label="Notes and resources panel" className="relative flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)]">
<PopButton label="Open in a larger window" onClick={() => setPopped(true)} />
{popped ? <PoppedNote onBack={() => setPopped(false)} /> : workPane}
</section>
)}
</aside>
</div>
{popped && selectedChunk && chapter && (
<PopOut title="Notes and resources" onClose={() => setPopped(false)}>{workPane}</PopOut>
)}
{comparing && selectedChunk && <CompareSheet currentChunkId={selectedChunk.id} onClose={() => setComparing(false)} />}
<SuggestWordsSheet wordStudy={wordStudy} />
{statusMessage && (
@@ -206,3 +197,21 @@ export default function StudyPage() {
</StudioShell>
);
}
function PopButton({ label, onClick }) {
return (
<button type="button" onClick={onClick} aria-label={label} title={label}
className="absolute right-2 top-2 z-10 hidden rounded-lg bg-[var(--r-bg)] p-1.5 text-[var(--r-sub)] hover:bg-black/5 hover:text-[var(--r-text)] sm:block">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 4h6v6M20 4l-8 8M10 6H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4" /></svg>
</button>
);
}
function PoppedNote({ onBack }) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center">
<p className="text-sm text-[var(--r-sub)]">Your notes and resources are open in a larger window.</p>
<button type="button" onClick={onBack} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5">Bring it back</button>
</div>
);
}
+29 -18
View File
@@ -419,33 +419,47 @@ describe('Study workspace', () => {
test('commentary: "Add to notes" quotes a paragraph with its citation', async () => {
const user = await studyOneChunk([['/api/c/matthew-henry/TIT/1.json', { chapter: { content: [{ type: 'verse', number: 1, content: ['Paul was an apostle.'] }] } }]]);
await user.click(screen.getByRole('tab', { name: /commentary/i }));
expect(screen.getByText('Observe first')).toBeInTheDocument(); // commentary waits for your own notes
await user.click(screen.getByRole('button', { name: /show it anyway/i }));
await user.click(await screen.findByRole('button', { name: /add to notes/i }));
expect(await screen.findByRole('status')).toHaveTextContent(/background notes/i);
await user.click(screen.getByRole('tab', { name: /^notes/i }));
await user.click(screen.getByRole('button', { name: /^more/i }));
expect(screen.getByLabelText(/background/i)).toHaveValue('Paul was an apostle. (Matthew Henry, Titus 1:1)');
});
test('commentary opens once you have written enough of your own Observe notes', async () => {
await studyOneChunk([['/api/c/matthew-henry/TIT/1.json', { chapter: { content: [{ type: 'verse', number: 1, content: ['Paul was an apostle.'] }] } }]]);
const user = await studyOneChunk([['/api/c/matthew-henry/TIT/1.json', { chapter: { content: [{ type: 'verse', number: 1, content: ['Paul was an apostle.'] }] } }]]);
await user.click(screen.getByRole('tab', { name: /commentary/i }));
expect(screen.getByText('Observe first')).toBeInTheDocument();
await user.click(screen.getByRole('tab', { name: /^notes/i }));
fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: Array.from({ length: 15 }, (_, i) => `word${i}`).join(' ') } });
await user.click(screen.getByRole('tab', { name: /commentary/i }));
expect(screen.queryByText('Observe first')).not.toBeInTheDocument();
expect(await screen.findByRole('button', { name: /add to notes/i })).toBeInTheDocument();
});
test('either half of the right column can be folded, and the other always stays open', async () => {
test('notes and resources share one column as tabs, with Notes first and open', async () => {
const user = await studyOneChunk();
const notes = screen.getByRole('button', { name: /your notes/i });
const resources = screen.getByRole('button', { name: /commentary & resources/i });
expect(notes).toHaveAttribute('aria-expanded', 'true');
await user.click(notes);
expect(notes).toHaveAttribute('aria-expanded', 'false');
expect(resources).toHaveAttribute('aria-expanded', 'true');
await user.click(resources); // folding the last open half opens the other one
expect(resources).toHaveAttribute('aria-expanded', 'false');
expect(notes).toHaveAttribute('aria-expanded', 'true');
const tabs = screen.getAllByRole('tab').map((t) => t.textContent.replace(/\s·\s\d+$/, ''));
expect(tabs.slice(0, 3)).toEqual(['Notes', 'Commentary', 'Word study']);
expect(screen.getByRole('tab', { name: /^notes/i })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('textbox', { name: 'Observe' })).toBeInTheDocument();
await user.click(screen.getByRole('tab', { name: /word study/i }));
expect(screen.queryByRole('textbox', { name: 'Observe' })).not.toBeInTheDocument();
await user.click(screen.getByRole('tab', { name: /^notes/i }));
expect(screen.getByRole('textbox', { name: 'Observe' })).toBeInTheDocument();
});
test('the chunk list is a card on the left edge that opens and closes', async () => {
const user = await studyOneChunk();
const toggle = screen.getByRole('button', { name: /^chunks · 1\/1$/i });
expect(toggle).toHaveAttribute('aria-expanded', 'false');
await user.click(toggle);
expect(toggle).toHaveAttribute('aria-expanded', 'true');
await user.keyboard('{Escape}');
expect(toggle).toHaveAttribute('aria-expanded', 'false');
});
test('a long paste asks if it is from AI; "I wrote it" takes the tag back', async () => {
@@ -578,18 +592,15 @@ describe('App bar', () => {
});
describe('Study layout', () => {
test('notes sit in their own box above the resources, not under the passage', async () => {
test('notes are the first tab of the right column, not under the passage', async () => {
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);
const notes = screen.getByRole('region', { name: 'Notes' });
const resources = screen.getByRole('region', { name: 'Resources' });
expect(within(notes).getByRole('textbox', { name: 'Observe' })).toBeInTheDocument();
expect(within(resources).getByRole('tab', { name: /commentary/i })).toBeInTheDocument();
// notes come first in the right column
expect(notes.compareDocumentPosition(resources) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
const column = screen.getByRole('region', { name: 'Notes and resources panel' });
expect(within(column).getByRole('textbox', { name: 'Observe' })).toBeInTheDocument();
expect(within(column).getAllByRole('tab')[0]).toHaveTextContent('Notes');
// and the passage column no longer holds them
expect(within(screen.getByRole('region', { name: 'Scripture' })).queryByRole('textbox')).not.toBeInTheDocument();
});
+26
View File
@@ -249,3 +249,29 @@ describe('Greek word lookup', () => {
});
});
describe('Pop-out window on the Study page', () => {
test('notes and resources open in a larger window, show a placeholder in the column, and Esc brings them back', async () => {
await goToStudyWith3Chunks();
expect(screen.getByRole('tab', { name: /word study/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /open in a larger window/i }));
const dialog = await screen.findByRole('dialog', { name: /notes and resources/i });
expect(dialog).toContainElement(screen.getByRole('tab', { name: /word study/i })); // the tabs moved into the window
expect(screen.getAllByRole('tab', { name: /word study/i })).toHaveLength(1);
expect(screen.getByText(/are open in a larger window/i)).toBeInTheDocument();
expect(dialog).toContainElement(screen.getByRole('textbox', { name: /observe/i }));
fireEvent.keyDown(window, { key: 'ArrowRight' });
expect(await findChunkCounter(1, 3)).toBeInTheDocument(); // chunk keys do nothing while it is open
fireEvent.keyDown(window, { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('dialog', { name: /notes and resources/i })).not.toBeInTheDocument());
expect(screen.getByRole('tab', { name: /word study/i })).toBeInTheDocument();
});
test('"Bring it back" closes the window', async () => {
await goToStudyWith3Chunks();
fireEvent.click(screen.getByRole('button', { name: /open in a larger window/i }));
await screen.findByRole('dialog', { name: /notes and resources/i });
fireEvent.click(screen.getByRole('button', { name: /bring it back/i }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: /notes and resources/i })).not.toBeInTheDocument());
});
});