From 7f0256be0617594dacfe502ee4912e93d2510e68 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:31:27 -0400 Subject: [PATCH] Hover tooltips, clickable cross-references, easier highlighting - Tooltips on hover and keyboard focus across the app: icon-only buttons show their label, existing titles show in the app's style without the browser's delay, and the reader's main buttons show shortcut hints ("Search (/)"). Touch screens are unaffected. - Reader: cross-references (under verses and in the Cross-refs tab) open the passage's text in the side panel without leaving the chapter; "Open chapter" goes there when wanted - Reader: the verse panel now has a Highlight row of colours for one-tap whole-verse highlighting (tap again to remove, Clear for all, with Undo); selecting words still highlights just those - Fix: a cross-reference preview was cleared when React ran the panel's effect twice (development builds) Co-Authored-By: Claude Sonnet 5.5 --- src/App.jsx | 2 + src/components/Tooltips.jsx | 112 +++++++++++++++++++++++++++ src/components/Tooltips.test.jsx | 88 +++++++++++++++++++++ src/components/reader/ReaderBars.jsx | 24 +++--- src/components/reader/ReaderText.jsx | 8 +- src/components/reader/VerseSheet.jsx | 107 ++++++++++++++++++++++--- src/components/study/PageShell.jsx | 2 +- src/pages/ReaderPage.jsx | 18 +++++ src/pages/ReaderPage.test.jsx | 61 ++++++++++++++- 9 files changed, 394 insertions(+), 28 deletions(-) create mode 100644 src/components/Tooltips.jsx create mode 100644 src/components/Tooltips.test.jsx diff --git a/src/App.jsx b/src/App.jsx index 284843f..b60322f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -6,6 +6,7 @@ import { ToastProvider, useToast } from './components/Toasts.jsx'; import CommandPalette from './components/CommandPalette.jsx'; import ShortcutsHelp from './components/ShortcutsHelp.jsx'; import SharedOutlineView from './components/SharedOutlineView.jsx'; +import Tooltips from './components/Tooltips.jsx'; import { useAudio } from './hooks/useAudio.js'; import { useAuth } from './hooks/useAuth.js'; import { useBibleReader } from './hooks/useBibleReader.js'; @@ -437,6 +438,7 @@ const AppInner = () => { {paletteOpen && setPaletteOpen(false)} />} {helpOpen && setHelpOpen(false)} />} + ); }; diff --git a/src/components/Tooltips.jsx b/src/components/Tooltips.jsx new file mode 100644 index 0000000..1ce6314 --- /dev/null +++ b/src/components/Tooltips.jsx @@ -0,0 +1,112 @@ +// Hover (and keyboard-focus) tooltips for the whole app, without touching every button: +// - anything with data-tip="…" shows that text (used for shortcut hints like "Search (/)") +// - anything with title="…" shows it too, in the app's style and without the browser's long delay +// - an icon-only button (no visible words) shows its aria-label +// Touch screens have no hover, so nothing appears there. +import { useEffect, useRef, useState } from 'react'; +import { themeVars } from './reader/readerThemes.js'; + +const SHOW_DELAY = 450; +const SELECTOR = '[data-tip], [title], button[aria-label], a[aria-label], [role="button"][aria-label], [role="tab"][aria-label]'; + +const canHover = () => (typeof window.matchMedia === 'function' ? window.matchMedia('(hover: hover)').matches : true); + +// The words a tooltip should show for an element, or '' for none. A title is moved to data-tip the first +// time (so the browser's own tooltip doesn't also appear); a title on a button with no other name becomes +// its aria-label so screen readers keep it. +export function tipFor(el) { + if (!(el instanceof HTMLElement)) return ''; + if (el.hasAttribute('title')) { + const title = el.getAttribute('title').trim(); + if (title) { + if (!el.getAttribute('aria-label') && el.textContent.replace(/\s+/g, '').length <= 2) el.setAttribute('aria-label', title); + if (!el.hasAttribute('data-tip')) el.setAttribute('data-tip', title); + } + el.removeAttribute('title'); + } + const tip = el.getAttribute('data-tip'); + if (tip) return tip; + // Icon-only controls (little or no text of their own) explain themselves with their label + const label = el.getAttribute('aria-label'); + const visible = el.textContent.replace(/\s+/g, '').length; + return label && visible <= 2 ? label : ''; +} + +export default function Tooltips({ theme }) { + const [tip, setTip] = useState(null); // { text, x, y, below } + const timer = useRef(null); + const current = useRef(null); + const box = useRef(null); + + useEffect(() => { + const hide = () => { + window.clearTimeout(timer.current); + if (current.current) current.current.removeAttribute('aria-describedby'); + current.current = null; + setTip(null); + }; + const show = (el, text) => { + const r = el.getBoundingClientRect(); + current.current = el; + el.setAttribute('aria-describedby', 'app-tooltip'); + setTip({ text, x: r.left + r.width / 2, y: r.top, bottom: r.bottom }); + }; + const find = (node) => (node instanceof Element ? node.closest(SELECTOR) : null); + + const onOver = (e) => { + if (!canHover()) return; + const el = find(e.target); + if (!el || el === current.current) return; + hide(); + const text = tipFor(el); + if (!text || el.disabled) return; + timer.current = window.setTimeout(() => show(el, text), SHOW_DELAY); + }; + const onOut = (e) => { + const el = find(e.target); + if (!el || (e.relatedTarget instanceof Node && el.contains(e.relatedTarget))) return; + hide(); + }; + const onFocus = (e) => { + const el = find(e.target); + if (!el || !el.matches(':focus-visible')) return; // only keyboard focus, not a mouse click + const text = tipFor(el); + if (text) { window.clearTimeout(timer.current); show(el, text); } + }; + const onKey = (e) => { if (e.key === 'Escape') hide(); }; + + document.addEventListener('pointerover', onOver); + document.addEventListener('pointerout', onOut); + document.addEventListener('pointerdown', hide, true); + document.addEventListener('focusin', onFocus); + document.addEventListener('focusout', hide); + document.addEventListener('keydown', onKey); + window.addEventListener('scroll', hide, true); + window.addEventListener('resize', hide); + return () => { + window.clearTimeout(timer.current); + document.removeEventListener('pointerover', onOver); + document.removeEventListener('pointerout', onOut); + document.removeEventListener('pointerdown', hide, true); + document.removeEventListener('focusin', onFocus); + document.removeEventListener('focusout', hide); + document.removeEventListener('keydown', onKey); + window.removeEventListener('scroll', hide, true); + window.removeEventListener('resize', hide); + }; + }, []); + + if (!tip) return null; + // Centred over the control, kept inside the window, and flipped below it when there's no room above + const width = box.current?.offsetWidth ?? 0; + const left = Math.max(8, Math.min(tip.x - width / 2, window.innerWidth - width - 8)); + const flip = tip.y < 40; + return ( + + ); +} diff --git a/src/components/Tooltips.test.jsx b/src/components/Tooltips.test.jsx new file mode 100644 index 0000000..f378f8b --- /dev/null +++ b/src/components/Tooltips.test.jsx @@ -0,0 +1,88 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { vi, beforeEach, afterEach, test, expect } from 'vitest'; +import Tooltips, { tipFor } from './Tooltips.jsx'; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +const over = (el) => act(() => { fireEvent.pointerOver(el); vi.advanceTimersByTime(600); }); + +function setup() { + render( + <> + + + + + + + , + ); +} + +test('shows a data-tip after a short pause, and hides when the pointer leaves', () => { + setup(); + const btn = screen.getByLabelText('Search'); + over(btn); + expect(screen.getByRole('tooltip')).toHaveTextContent('Search (/)'); + expect(btn).toHaveAttribute('aria-describedby', 'app-tooltip'); + act(() => { fireEvent.pointerOut(btn); }); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + expect(btn).not.toHaveAttribute('aria-describedby'); +}); + +test('does not appear before the pause', () => { + setup(); + act(() => { fireEvent.pointerOver(screen.getByLabelText('Search')); vi.advanceTimersByTime(100); }); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); +}); + +test('an icon-only button shows its label', () => { + setup(); + over(screen.getByLabelText('Close')); + expect(screen.getByRole('tooltip')).toHaveTextContent('Close'); +}); + +test('a title is shown in the app style and the browser\'s own tooltip is turned off', () => { + setup(); + const btn = screen.getByText('←'); + over(btn); + expect(screen.getByRole('tooltip')).toHaveTextContent('Out a level (Shift+Tab)'); + expect(btn).not.toHaveAttribute('title'); + expect(btn).toHaveAttribute('aria-label', 'Out a level (Shift+Tab)'); // a screen reader still gets its name +}); + +test('a button that already says what it is gets no tooltip, and neither does a disabled one', () => { + setup(); + over(screen.getByText('Save changes')); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + over(screen.getByLabelText('Nothing')); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); +}); + +test('clicking, scrolling or Escape dismisses it', () => { + setup(); + const btn = screen.getByLabelText('Search'); + over(btn); + act(() => { fireEvent.pointerDown(btn); }); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + over(btn); + act(() => { fireEvent.keyDown(document, { key: 'Escape' }); }); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + over(btn); + act(() => { fireEvent.scroll(window); }); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); +}); + +test('nothing shows on a device without hover (touch)', () => { + vi.stubGlobal('matchMedia', () => ({ matches: false })); + setup(); + over(screen.getByLabelText('Search')); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + vi.unstubAllGlobals(); +}); + +test('tipFor ignores things that are not elements', () => { + expect(tipFor(null)).toBe(''); + expect(tipFor(document.createTextNode('x'))).toBe(''); +}); diff --git a/src/components/reader/ReaderBars.jsx b/src/components/reader/ReaderBars.jsx index 9e64073..ec02543 100644 --- a/src/components/reader/ReaderBars.jsx +++ b/src/components/reader/ReaderBars.jsx @@ -9,9 +9,9 @@ export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, return (
- + {onDoneDrawing ? ( @@ -21,12 +21,12 @@ export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, ) : ( <> - - + - - + + )}
@@ -42,11 +42,11 @@ export function BottomBar({ return (
- - - - - + + + + +
{primary && ( )} - +
); diff --git a/src/components/reader/ReaderText.jsx b/src/components/reader/ReaderText.jsx index c2ad126..47c20fe 100644 --- a/src/components/reader/ReaderText.jsx +++ b/src/components/reader/ReaderText.jsx @@ -18,7 +18,7 @@ export default function ReaderText({ title, translation, translationId, notice, verses, loading, error, layout, fontSize, wide, font = 'serif', spacing = 'normal', showVerseNumbers = true, parallel = null, showDiff = true, bookmarks, bookKey, chapter, selectedVerse, onSelectVerse, renderVerseText, searchLower, - crossRefs, showCrossRefs, onSelectionEnd, footer, + crossRefs, showCrossRefs, onOpenCrossRef, onSelectionEnd, footer, }) { // How much the second Bible's wording differs from the first, over the chapter and verse by verse const diff = useMemo( @@ -56,7 +56,11 @@ export default function ReaderText({ const chips = (v) => showCrossRefs && crossRefs?.[v.number]?.length > 0 && ( {crossRefs[v.number].slice(0, 4).map((ref, i) => ( - {formatCrossRef(ref)} + // Opens that passage in the side panel; stopPropagation so the verse itself isn't also toggled + ))} ); diff --git a/src/components/reader/VerseSheet.jsx b/src/components/reader/VerseSheet.jsx index cb43bdc..15a0b2a 100644 --- a/src/components/reader/VerseSheet.jsx +++ b/src/components/reader/VerseSheet.jsx @@ -1,6 +1,6 @@ // The single home for per-verse tools: original words, cross-references, // commentary, and people & places, plus bookmark / copy. -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import Sheet from './Sheet.jsx'; import { CommentaryTab, PeoplePlacesTab } from '../StudyPanel.jsx'; import { BookmarkIcon, CopyIcon } from '../../utils/bibleUtils.jsx'; @@ -8,6 +8,7 @@ import { formatCrossRef, speakOriginalWord } from '../../utils/wordStudy.js'; import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js'; import { diffVerses, differenceLabel } from '../../utils/textDiff.js'; import { DiffText } from './DiffText.jsx'; +import { HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js'; const TABS = [['commentary', 'Commentary'], ['compare', 'Compare'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']]; @@ -37,18 +38,59 @@ function OriginalTab({ words }) { ); } -function RefsTab({ refs, loading, onJump }) { +// A cross-referenced passage shown in the panel: its words in the translation being read, without +// leaving the chapter. "Open chapter" goes there when you want more. +function RefPreview({ refItem, translation, onOpen, onClose }) { + const [state, setState] = useState({ key: '', verses: [], error: '' }); + const key = `${translation}/${refItem.book}/${refItem.chapter}`; + useEffect(() => { + const controller = new AbortController(); + fetchChapter(translation, refItem.book, refItem.chapter, { signal: controller.signal }) + .then(({ verses }) => setState({ key, verses, error: '' })) + .catch((err) => { if (!controller.signal.aborted) setState({ key, verses: [], error: err.message || 'Unable to load that passage.' }); }); + return () => controller.abort(); + }, [key]); // eslint-disable-line react-hooks/exhaustive-deps + + const end = refItem.endVerse && refItem.endVerse > refItem.verse ? refItem.endVerse : refItem.verse; + const picked = state.key === key ? state.verses.filter((v) => v.number >= refItem.verse && v.number <= end) : []; + const loading = state.key !== key; + return ( +
+
+

{formatCrossRef(refItem)} {translation}

+ +
+ {loading ?

Loading…

+ : state.error ?

{state.error}

+ : picked.length === 0 ?

Those verses weren’t found.

+ : ( +

+ {picked.map((v) => {picked.length > 1 && {v.number}}{v.text}{' '})} +

+ )} + +
+ ); +} + +const sameRef = (a, b) => a && b && a.book === b.book && a.chapter === b.chapter && a.verse === b.verse && (a.endVerse ?? a.verse) === (b.endVerse ?? b.verse); + +function RefsTab({ refs, loading, active, onPick, preview }) { if (loading && !refs) return

Loading…

; if (!refs?.length) return

No cross-references for this verse.

; return ( -
- {refs.slice(0, 20).map((ref, i) => ( - - ))} -
+ <> + {preview} +

Tap one to read it here.

+
+ {refs.slice(0, 20).map((ref, i) => ( + + ))} +
+ ); } @@ -99,10 +141,27 @@ function CompareTab({ book, chapter, verse, current }) { export default function VerseSheet({ verse, book, bookName, chapter, translation, words, crossRefs, crossRefsLoading, onLoadCrossRefs, - bookmarkColor, onToggleBookmark, onCycleColor, onJump, onClose, + bookmarkColor, onToggleBookmark, onCycleColor, onJump, onClose, request, + verseHighlights = [], colorLabels = {}, onHighlightVerse, onClearVerseHighlights, }) { const [tab, setTab] = useState('commentary'); const [copied, setCopied] = useState(false); + const [preview, setPreview] = useState(null); // the cross-reference being read in the panel + const appliedRequest = useRef(0); + // Leaving this verse closes the preview; a cross-reference tapped in the text opens it here. (Tracks the + // previous verse so React's double-run of effects in development can't clear a preview it just opened.) + const previousVerse = useRef(null); + useEffect(() => { + const verseChanged = previousVerse.current !== verse?.number; + previousVerse.current = verse?.number; + if (request && request.verse === verse?.number && request.n !== appliedRequest.current) { + appliedRequest.current = request.n; + setTab('refs'); + setPreview(request.ref); + } else if (verseChanged) { + setPreview(null); + } + }, [verse?.number, request?.n]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (verse && tab === 'refs') onLoadCrossRefs(); }, [verse?.number, tab]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { setCopied(false); }, [verse?.number]); if (!verse) return null; @@ -134,6 +193,27 @@ export default function VerseSheet({ )} > + {onHighlightVerse && ( + // One-tap highlighting of the whole verse (to mark just some words, select them in the text) +
+ Highlight + {HIGHLIGHT_COLORS.map((color) => { + const on = verseHighlights.some((h) => h.color === color && h.startOffset === 0 && h.endOffset >= verse.text.length); + return ( + + ); + })} + {verseHighlights.length > 0 && ( + + )} + Or select words in the text to highlight just those. +
+ )}
{TABS.map(([id, label]) => ( diff --git a/src/pages/ReaderPage.jsx b/src/pages/ReaderPage.jsx index 0921d10..db8994c 100644 --- a/src/pages/ReaderPage.jsx +++ b/src/pages/ReaderPage.jsx @@ -106,6 +106,7 @@ export default function ReaderPage() { const [chromeVisible, setChromeVisible] = useState(true); const [readerDrawMode, setReaderDrawMode] = useState(false); const [selectionPicker, setSelectionPicker] = useState(null); + const [xrefRequest, setXrefRequest] = useState(null); // a cross-reference tapped in the text: { verse, ref, n } const swipeTouchRef = useRef(null); const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? NO_STROKES; @@ -298,6 +299,18 @@ export default function ReaderPage() { removeTextHighlight(id); if (gone) toast('Highlight removed', { action: { label: 'Undo', onClick: () => restoreTextHighlight(gone) } }); }; + // Highlighting a whole verse from the verse panel: tapping the same colour again takes it off. + const highlightVerse = (color) => { + if (!selectedVerse) return; + const existing = verseHighlights.find((h) => h.color === color && h.startOffset === 0 && h.endOffset >= selectedVerse.text.length); + if (existing) { removeHighlight(existing.id); return; } + addTextHighlight({ book: readerBookAbbrev, chapter: readerChapter, verse: selectedVerse.number, startOffset: 0, endOffset: selectedVerse.text.length, color, text: selectedVerse.text }); + }; + const clearVerseHighlights = () => { + const gone = verseHighlights; + gone.forEach((h) => removeTextHighlight(h.id)); + if (gone.length) toast(`Removed ${gone.length} highlight${gone.length === 1 ? '' : 's'}`, { action: { label: 'Undo', onClick: () => gone.forEach((h) => restoreTextHighlight(h)) } }); + }; const removeBookmark = (key, color) => { toggleReaderBookmark(key); toast('Bookmark removed', { action: { label: 'Undo', onClick: () => restoreReaderBookmark?.(key, color) } }); @@ -387,6 +400,7 @@ export default function ReaderPage() { }).sort((a, b) => a.bookIndex - b.bookIndex || a.chapter - b.chapter || a.verse - b.verse || a.startOffset - b.startOffset); const selectedVerse = readerSelectedVerse != null ? readerVerses.find((v) => v.number === readerSelectedVerse) : null; + const verseHighlights = selectedVerse ? (readerTextHighlights || []).filter((h) => h.book === readerBookAbbrev && h.chapter === readerChapter && h.verse === selectedVerse.number) : []; const selectedKey = selectedVerse ? `${readerBookAbbrev}-${readerChapter}-${selectedVerse.number}` : null; const title = `${readerBook?.name ?? ''} ${readerChapter} · ${readerTranslation}`; const audio = { @@ -459,6 +473,7 @@ export default function ReaderPage() { searchLower={chapterSearch} crossRefs={readerCrossRefs} showCrossRefs={readerShowCrossRefs} + onOpenCrossRef={(verse, ref) => { setReaderSelectedVerse(verse); setXrefRequest((r) => ({ verse, ref, n: (r?.n ?? 0) + 1 })); }} onSelectionEnd={handleSelectionEnd} footer={( cycleBookmarkColor(selectedKey)} onJump={jump} onClose={() => setReaderSelectedVerse(null)} + request={xrefRequest} + verseHighlights={verseHighlights} colorLabels={readerColorLabels ?? {}} + onHighlightVerse={highlightVerse} onClearVerseHighlights={clearVerseHighlights} /> )} diff --git a/src/pages/ReaderPage.test.jsx b/src/pages/ReaderPage.test.jsx index 1702c9b..ac1c3c3 100644 --- a/src/pages/ReaderPage.test.jsx +++ b/src/pages/ReaderPage.test.jsx @@ -1,4 +1,5 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'; +import { StrictMode } from 'react'; import { vi, test, expect } from 'vitest'; const noop = () => {}; @@ -58,6 +59,64 @@ test('the compare button next to Aa opens the comparison sheet, and choosing a B expect(ctx.setReaderParallel).toHaveBeenCalledWith('NET'); }); +test('a cross-reference under a verse opens the side panel with that passage, without leaving the chapter', async () => { + const saved = { ...ctx }; + Object.assign(ctx, { + readerShowCrossRefs: true, readerSelectedVerse: 1, + readerCrossRefs: { 1: [{ book: 'ROM', chapter: 5, verse: 8, endVerse: 9 }] }, + jumpToReaderVerse: vi.fn(), + }); + vi.stubGlobal('fetch', vi.fn((url) => Promise.resolve({ + ok: true, + json: () => Promise.resolve(String(url).includes('/api/bible/') + ? { verses: [{ number: 7, text: 'Seven.' }, { number: 8, text: 'But God shows His love for us.' }, { number: 9, text: 'Much more shall we be saved.' }, { number: 10, text: 'Ten.' }] } + : { chapter: { content: [] } }), + }))); + render(); // dev builds run effects twice; the preview must survive that + const chip = within(screen.getByRole('article')).getByRole('button', { name: 'Romans 5:8-9' }); + fireEvent.click(chip); + const card = await screen.findByRole('region', { name: /cross-reference romans 5:8-9/i }); + await waitFor(() => expect(within(card).getByText(/But God shows His love/)).toBeTruthy()); + expect(within(card).getByText(/Much more shall we be saved/)).toBeTruthy(); + expect(within(card).queryByText('Seven.')).toBeNull(); // only the verses in the reference + expect(ctx.setReaderSelectedVerse).not.toHaveBeenCalledWith(null); // the click didn't toggle the verse off + fireEvent.click(within(card).getByRole('button', { name: /open chapter/i })); + expect(ctx.jumpToReaderVerse).toHaveBeenCalledWith('ROM', 5, 8); // only now does it leave the chapter + fireEvent.click(within(card).getByRole('button', { name: /close cross-reference/i })); + expect(screen.queryByRole('region', { name: /cross-reference romans/i })).toBeNull(); + Object.assign(ctx, saved); + vi.unstubAllGlobals(); +}); + +test('the verse panel has one-tap highlight colours for the whole verse', () => { + const saved = { ...ctx }; + Object.assign(ctx, { readerSelectedVerse: 2, addTextHighlight: vi.fn(), removeTextHighlight: vi.fn(), restoreTextHighlight: vi.fn(), readerTextHighlights: [] }); + render(); + const row = screen.getByRole('group', { name: /highlight this verse/i }); + fireEvent.click(within(row).getByRole('button', { name: /highlight verse green/i })); + expect(ctx.addTextHighlight).toHaveBeenCalledWith(expect.objectContaining({ + book: 'JHN', chapter: 3, verse: 2, startOffset: 0, endOffset: 'This man came to Jesus by night.'.length, color: '#bbf7d0', text: 'This man came to Jesus by night.', + })); + Object.assign(ctx, saved); +}); + +test('tapping a verse colour again takes it off, and Clear removes them all (with Undo)', () => { + const saved = { ...ctx }; + const text = 'This man came to Jesus by night.'; + const hl = (id, color, start = 0, end = text.length) => ({ id, book: 'JHN', chapter: 3, verse: 2, startOffset: start, endOffset: end, color }); + Object.assign(ctx, { readerSelectedVerse: 2, addTextHighlight: vi.fn(), removeTextHighlight: vi.fn(), restoreTextHighlight: vi.fn(), readerTextHighlights: [hl('a', '#bbf7d0'), hl('b', '#fef08a', 5, 8)] }); + render(); + const row = screen.getByRole('group', { name: /highlight this verse/i }); + expect(within(row).getByRole('button', { name: /highlight verse green/i })).toHaveAttribute('aria-pressed', 'true'); + expect(within(row).getByRole('button', { name: /highlight verse yellow/i })).toHaveAttribute('aria-pressed', 'false'); // a part-verse highlight isn't "the verse" + fireEvent.click(within(row).getByRole('button', { name: /highlight verse green/i })); + expect(ctx.removeTextHighlight).toHaveBeenCalledWith('a'); + expect(ctx.addTextHighlight).not.toHaveBeenCalled(); + fireEvent.click(within(row).getByRole('button', { name: /^clear$/i })); + expect(ctx.removeTextHighlight).toHaveBeenCalledWith('b'); + Object.assign(ctx, saved); +}); + test('tapping a verse selects it', () => { render(); fireEvent.click(screen.getByText(/This man came to Jesus/));