diff --git a/.changeset/verse-action-popover.md b/.changeset/verse-action-popover.md new file mode 100644 index 00000000..fde935b0 --- /dev/null +++ b/.changeset/verse-action-popover.md @@ -0,0 +1,5 @@ +--- +"@youversion/platform-react-ui": minor +--- + +Add a verse action popover to `BibleReader`. Tapping verses selects them (shown with an underline) and opens a popover anchored to the last-selected verse with five highlight colors, Copy, and Share. Highlights apply a translucent fill, persist to `localStorage` per Bible version (shaped like the future highlight API), and can be removed individually. Copy/Share output mirrors bible.com formatting: the verse text in curly quotes, gaps in a non-contiguous selection joined with `...`, followed by the `Book Chapter:verses VERSION` reference. Share uses the Web Share API and falls back to copying where it isn't available. diff --git a/docs/adr/YPE-642-verse-action-popover.md b/docs/adr/YPE-642-verse-action-popover.md new file mode 100644 index 00000000..ec13acf8 --- /dev/null +++ b/docs/adr/YPE-642-verse-action-popover.md @@ -0,0 +1,176 @@ +# YPE-642 — Verse Action Popover (highlights, copy, share) + +Status: **In design** (grilling session 2026-06-23) +Component: `packages/ui/src/components/bible-reader.tsx` (+ `verse.tsx`) +Prior art: PR #131 (CLOSED) — salvage, don't rebuild. + +## Why #131 stalled + +The popover logic was never the problem. It died because the **Bible HTML +structure** couldn't render highlights cleanly (line gaps, footnote color +breaks), and fixing that + getting stakeholder buy-in took a long time. That +structure fix has since landed: verses are wrapped in one-or-more `.yv-v[v="N"]` +elements, so a per-verse background can apply as a solid block. **The blocker is +gone.** #131's `VerseActionPopover` (correct AC logic, tested, already uses Radix +`PopoverAnchor virtualRef`) is salvage-grade. + +## Glossary + +| Term | Meaning | +|---|---| +| **Selection** | Verses currently tapped. Ephemeral. Drives the popover. Keyed by verse number within the rendered chapter. | +| **Highlight** | A persisted color on a verse. Survives deselection. Modeled like the API `highlight` object. | +| **Active highlights** | Distinct colors present across the current selection → drives the X (remove) buttons. | +| **Apply circle** | Color button that adds a highlight. | +| **Remove circle (X)** | Color button that clears an existing highlight. | +| **Anchor** | DOM element the popover triangle points at — the last-selected verse's `.yv-v` element. | +| **Swatch** | The full-saturation color shown in the circle. | +| **Fill** | The faded (~20-30% alpha) background painted on the verse. | + +## Decisions (ADRs) + +### ADR-001 — localStorage only this PR +Highlights persist client-side only. Server sync is a **separate ticket**. +No network, no API client this PR. + +### ADR-002 — Local model mirrors the future API `highlight` object +The API shape (from spec) is: +``` +highlight { + bible_id: int32 // e.g. 3034 + passage_id: string // verse USFM, e.g. "MAT.1.1" + color: string // /^[0-9a-f]{6}$/ hex, no '#', e.g. "44aa44" +} +``` +Local store is shaped the same so the API swap later is mechanical: +- Keyed/scoped by `bible_id` + `passage_id` (full verse USFM). +- `passage_id` derived in-chapter as `${book}.${chapter}.${verseNumber}` from + BibleReader context. +- In-render, `verse.tsx` still works in verse **numbers**; the persistence layer + maps number ↔ `passage_id`. The on-wire/on-disk truth is USFM. + +### ADR-003 — Salvage #131's popover, don't rewrite +Bring back `verse-action-popover.tsx` + its tests verbatim where possible. It +already implements all 9 ACs and uses Radix `PopoverAnchor virtualRef`. Changes +needed: real YV colors (ADR-005), alpha fill (ADR-005), wire into BibleReader +(ADR-004), real copy/share formatting (open). **Drop** the vestigial +`@oddbird/css-anchor-positioning` polyfill — the popover never used it. + +### ADR-004 — BibleReader.Root owns selection + highlights; BibleTextView stays presentational +- Selection state and the highlight map live in `BibleReader.Root` context, + using the existing `useControllableState` idiom (uncontrolled by default; + optional controlled `highlights` / `onHighlightsChange`, `onCopy`, `onShare`). +- `BibleTextView` stays dumb: receives `selectedVerses` + `highlightedVerses` + (color map), emits `onVerseSelect`. It already does exactly this — we change + the highlight value type from `boolean` to color hex and wire the props that + `Content` currently omits. +- The popover lives in `Content`, opens when selection is non-empty, anchored + via `PopoverAnchor virtualRef` to the last-selected `.yv-v` element resolved by + `querySelector('.yv-v[v="N"]')`. +- **Breaking-ish:** `highlightedVerses` changes `Record` → + `Record` (hex). It's wired nowhere in the composite today, so + blast radius is small, but it is a public prop on `BibleTextView`. + +### ADR-005 — Hardcoded hex palette, matching the iOS app +Theme tokens don't carry these exact colors (the iOS app hardcodes them), so the +palette is hardcoded here too. Simpler than mapping to tokens. + +| Highlight | Hex (stored / API + swatch) | +|---|---| +| yellow | `fffe00` | +| green | `5dff79` | +| blue | `00d6ff` | +| orange | `ffc66f` | +| pink | `ff95ef` | + +- Lowercased to satisfy the API `color` pattern `/^[0-9a-f]{6}$/`. +- **Swatch** (circle) = `#` solid + a `1px #121212 @ 20%` inner stroke + (applies to all swatches). +- **Fill** (verse) = the hex at **35% opacity** behind the text + (`rgba(, 0.35)`, `HIGHLIGHT_FILL_OPACITY` in `verse.tsx`). +- **Active/remove swatch** = the solid color circle with a **24px X icon** in the + Text/Everdark color (`--yv-gray-50` = `#121212`, theme-invariant) — replaces the + old stroke-based selected indicator. +- No theme tokens, no dark variant. + +### ADR-006 — Copy/Share format = bible.com behavior (supersedes AC3) +AC3's inline `"text" - Book Ch:V Version` is **wrong**. The real format, per +Cam's example, mimics bible.com: +``` + + + : +``` +- Text and reference separated by a blank line (`\n\n`). No dash. +- Non-contiguous verses → ` ... ` between the gap (e.g. selecting v1+v3 of + Proverbs 19 → `…perverse. ... A person's own folly…`). +- Reference: full book name, `1-3` for contiguous range, `1,3` for + non-contiguous, `1` for single. Version = abbreviation. +- Verse **numbers and footnote markers must be stripped** from copied text — + `.yv-v` textContent includes them; clean prose only. +- Share = same string, Web Share `{ text }`, **no URL / deep link**. +- Quote-character style (straight vs curly, single vs double) is OPEN — match + bible.com exactly. + +## Resolved (round 2) +- Palette → theme.css expressive (ADR-005). Copy format → bible.com (ADR-006). +- Share = text only, no deep link. No auth gating. Apply/copy/share + outside + click all clear selection and close the popover. + +## Resolved (round 3) +- **Copy text cleaning:** strip verse numbers + footnote markers; clean prose only. +- **Multi-wrapper verses:** highlight paints every `.yv-v[v="N"]` wrapper; copy + concatenates them in document order. +- **Per-version:** highlights scoped by `bible_id` (NIV highlight ≠ ESV). Yes. +- **localStorage:** key `yv:highlights:` → `{ "": "" }`. + Load on mount + version change; filter to visible chapter for render. +- **Quote char:** curly double `"…"` wrapping the whole text block. +- **No cap.** No cross-chapter highlighting, so selection is bounded by the + chapter's verse count. Number ↔ passage_id mapping is always sufficient. + +### ADR-007 — Selection lifecycle tied to navigation +Changing `book`, `chapter`, or `versionId` clears selection and closes the +popover (those verses no longer exist / highlights reload for the new scope). +Selection is always enabled in BibleReader (no opt-out prop for now; YAGNI). + +### ADR-008 — Selection visual = underline; stacks over highlight fill +- Selected (not highlighted): **underline** (bottom border) in the foreground + color, touching the text bottom (the Notion "underline has an offset" note was + about fixing this exact thing). +- Highlighted: 25%-alpha color fill (ADR-005). +- Selected **and** highlighted: fill stays, underline drawn on top — reads as + both. Underline over a color fill is legible; a second bg wouldn't be. +- Tunable; swap to a ring/bg later if Figma says otherwise. + +## As-built notes (deviations from the design above) +- **ADR-004 revised:** selection + highlights live in `Content`, **not** Root + context. Copy/Share/anchor all need the rendered verse DOM (which lives in + Content), so Root ownership would fragment the feature. BibleTextView stays + presentational. No new Root props — smaller API surface. +- **ADR-005 mechanism:** fill uses `rgba(r,g,b,0.25)` (computed from the stored + hex in `verse.tsx` `hexToRgba`), not `color-mix`. Same alpha-composite result, + zero browser-support caveats. +- **Verse-tap vs outside-click:** ADR-007 says outside-click clears selection, + but Radix treats a *second verse tap* as an outside-click too. The popover's + `onInteractOutside` calls `preventDefault()` when the target is inside + `.yv-v[v]`, so tapping more verses re-anchors instead of dismissing. Only a + true outside tap clears (matches the YV apps). +- **localStorage key:** `youversion-platform:highlights:` → + `{ "": "" }`. +- **Files:** new `verse-action-popover.tsx` (+ tests, restored from #131), + `lib/verse-share.ts` (+ tests), `icons/box-stack`, `icons/box-arrow-up`; + `verse.tsx` (color fill + `getCleanVerseText`), `bible-reader.tsx` (Content + wiring), `verse.stories.tsx` (VerseSelection story now drives the real + popover), i18n (en/fr/es), `global.css` (selection underline). The `@oddbird` + polyfill was never reintroduced — the popover anchors via Radix `virtualRef`. + +## Build-time risks (not blocking design, flag for implementation) +- **Footnote color break** (Notion): even post structure-fix, ``/footnote + markers inside a verse may interrupt the fill. Verify the fill covers them. +- **Footnote contrast (handled):** on a highlighted verse the footnote marker + switches from `--yv-gray-20` to `--yv-foreground` (theme-adaptive, AA over all + 5 fills in both themes) via the `isHighlighted` prop on `VerseFootnoteButton`. +- `verse.tsx` uses `useLayoutEffect` (line 283) for the class toggle; AGENTS.md + (SSR) says prefer `useEffect`. Pre-existing; clean up while here. +- #131 leftovers to handle: replace the old verse-selection Storybook story + + remove demo highlight CSS; add a changeset. diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index 1b8a49be..1d1bb64e 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -22,6 +22,7 @@ import React, { useLayoutEffect, useMemo, useRef, + useState, type ReactElement, type ReactNode, } from 'react'; @@ -36,7 +37,9 @@ import { LoaderIcon } from './icons/loader'; import { PersonIcon } from './icons/person'; import { Button } from './ui/button'; import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from './ui/popover'; -import { BibleTextView, type FootnoteData } from './verse'; +import { VerseActionPopover } from './verse-action-popover'; +import { BibleTextView, getCleanVerseText, type FootnoteData } from './verse'; +import { buildVerseShareText } from '@/lib/verse-share'; type BibleReaderContextType = { book: string; @@ -462,6 +465,177 @@ function Content() { scrollContainerRef.current?.scrollTo({ top: 0 }); }, [book, chapter]); + // ---- Verse selection + highlights ------------------------------------------ + // Selection is ephemeral; highlights persist to localStorage only for now + // (ADR-001) in the future `highlight` API shape: keyed by full passage_id USFM + // and scoped by versionId/bible_id (ADR-002). The reader DOM ref lets us anchor + // the popover and pull clean verse text for Copy / Share. + const readerRef = useRef(null); + const [selectedVerses, setSelectedVerses] = useState([]); + const [popoverOpen, setPopoverOpen] = useState(false); + const [anchorElement, setAnchorElement] = useState(null); + const lastSelectionRef = useRef([]); + const [highlightStore, setHighlightStore] = useState>({}); + + const highlightsStorageKey = `youversion-platform:highlights:${versionId}`; + + // Load this version's highlights when the version changes (client-only). + useEffect(() => { + let data: Record = {}; + try { + const raw = localStorage.getItem(highlightsStorageKey); + if (raw) data = JSON.parse(raw) as Record; + } catch { + // Ignore (unavailable or malformed storage). + } + setHighlightStore(data); + }, [highlightsStorageKey]); + + // Navigating away (book/chapter/version) drops the selection — those verses no + // longer exist on screen (ADR-007). + useEffect(() => { + setSelectedVerses([]); + setPopoverOpen(false); + setAnchorElement(null); + lastSelectionRef.current = []; + }, [book, chapter, versionId]); + + // Derive the visible chapter's highlights (verse number → hex) from the store. + const chapterPrefix = `${book}.${chapter}.`; + const highlightedVerses = useMemo(() => { + const map: Record = {}; + for (const [passageId, color] of Object.entries(highlightStore)) { + if (!passageId.startsWith(chapterPrefix)) continue; + const verseNum = parseInt(passageId.slice(chapterPrefix.length), 10); + if (verseNum) map[verseNum] = color; + } + return map; + }, [highlightStore, chapterPrefix]); + + // Distinct colors present in the current selection → drives the X (remove) circles. + const activeHighlights = useMemo( + () => + new Set( + selectedVerses + .map((verse) => highlightedVerses[verse]) + .filter((color): color is string => Boolean(color)), + ), + [selectedVerses, highlightedVerses], + ); + + function persistHighlights(next: Record) { + try { + localStorage.setItem(highlightsStorageKey, JSON.stringify(next)); + } catch { + // Ignore (private mode / quota exceeded). + } + } + + function closeAndClearSelection() { + setPopoverOpen(false); + setSelectedVerses([]); + setAnchorElement(null); + lastSelectionRef.current = []; + } + + function handleVerseSelect(verses: number[]) { + const added = verses.find((verse) => !lastSelectionRef.current.includes(verse)); + lastSelectionRef.current = verses; + setSelectedVerses(verses); + + if (verses.length === 0) { + setPopoverOpen(false); + setAnchorElement(null); + return; + } + + // Anchor to the most recently tapped verse (falls back to the last by number + // when a verse was removed), using its final wrapper so the caret sits at the + // verse's visual bottom. + const anchorVerse = added ?? Math.max(...verses); + const wrappers = readerRef.current?.querySelectorAll(`.yv-v[v="${anchorVerse}"]`); + const anchor = wrappers?.[wrappers.length - 1]; + setAnchorElement(anchor instanceof HTMLElement ? anchor : null); + setPopoverOpen(true); + } + + function handleHighlight(color: string) { + const next = { ...highlightStore }; + for (const verse of selectedVerses) { + next[`${book}.${chapter}.${verse}`] = color; + } + setHighlightStore(next); + persistHighlights(next); + closeAndClearSelection(); + } + + function handleClearHighlight(color: string) { + const next = { ...highlightStore }; + for (const verse of selectedVerses) { + const passageId = `${book}.${chapter}.${verse}`; + if (next[passageId] === color) delete next[passageId]; + } + setHighlightStore(next); + persistHighlights(next); + + // Multiple colors active → keep open so the user can remove others (AC 8a); + // last color removed → dismiss (AC 8). + const hasRemaining = selectedVerses.some((verse) => { + const current = highlightedVerses[verse]; + return current && current !== color; + }); + if (!hasRemaining) closeAndClearSelection(); + } + + function buildSelectionText(): string { + const container = readerRef.current; + if (!container) return ''; + const textByVerse: Record = {}; + for (const verse of selectedVerses) { + textByVerse[verse] = getCleanVerseText(container, verse); + } + return buildVerseShareText({ + verses: selectedVerses, + textByVerse, + bookName: bookData?.title ?? book, + chapter, + versionAbbreviation: version?.localized_abbreviation ?? '', + }); + } + + function handleCopy() { + const text = buildSelectionText(); + if (text) void navigator.clipboard?.writeText(text); + closeAndClearSelection(); + } + + function handleShare() { + const text = buildSelectionText(); + if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') { + navigator + .share({ text }) + .then(() => closeAndClearSelection()) + .catch(() => { + // Cancelled or failed — keep the popover open (AC 4). + }); + return; + } + // No Web Share support (e.g. most desktop browsers) — fall back to clipboard. + if (text && typeof navigator !== 'undefined') { + void navigator.clipboard?.writeText(text); + } + closeAndClearSelection(); + } + + function handlePopoverOpenChange(open: boolean) { + if (open) { + setPopoverOpen(true); + return; + } + // Outside click / Escape closes and clears (ADR-007). + closeAndClearSelection(); + } + let chapterLabel: string = bookData?.chapters?.find((ch) => ch.id === chapter)?.title || chapter; if (bookData?.intro && chapter === bookData?.intro.id) { chapterLabel = bookData.intro.title; @@ -500,6 +674,7 @@ function Content() { )} > + 0} + onOpenChange={handlePopoverOpenChange} + activeHighlights={activeHighlights} + selectedVerses={selectedVerses} + highlightedVerses={highlightedVerses} + anchorElement={anchorElement} + scrollRoot={scrollContainerRef.current} + onHighlight={handleHighlight} + onClearHighlight={handleClearHighlight} + onCopy={handleCopy} + onShare={handleShare} + theme={background} + /> + {showLoadingOverlay ? (
): ReactElement { + return ( + + + + + ); +} diff --git a/packages/ui/src/components/icons/box-stack.tsx b/packages/ui/src/components/icons/box-stack.tsx new file mode 100644 index 00000000..dcb1ae51 --- /dev/null +++ b/packages/ui/src/components/icons/box-stack.tsx @@ -0,0 +1,25 @@ +import type { ComponentProps, ReactElement } from 'react'; + +export function BoxStackIcon(props: ComponentProps<'svg'>): ReactElement { + return ( + + + + + ); +} diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 95c803fb..cbd55e70 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -45,6 +45,10 @@ export { FootnoteContent, type FootnoteContentProps, } from './verse'; +// NOTE: the popover's `HighlightColor` (a hex-string union) is intentionally not +// re-exported here — core already exports a different `HighlightColor` (the API's +// { id, color, label } shape). Import it from './verse-action-popover' if needed. +export { VerseActionPopover, HIGHLIGHT_COLORS } from './verse-action-popover'; export { BibleCard, type BibleCardProps } from './bible-card'; export { Separator } from './ui/separator'; export { Textarea } from './ui/textarea'; diff --git a/packages/ui/src/components/verse-action-popover.test.tsx b/packages/ui/src/components/verse-action-popover.test.tsx new file mode 100644 index 00000000..074b8902 --- /dev/null +++ b/packages/ui/src/components/verse-action-popover.test.tsx @@ -0,0 +1,470 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { VerseActionPopover, HIGHLIGHT_COLORS, type HighlightColor } from './verse-action-popover'; + +describe('VerseActionPopover', () => { + const defaultProps = { + open: true, + onOpenChange: vi.fn(), + activeHighlights: new Set(), + selectedVerses: [], + highlightedVerses: {}, + anchorElement: null, + onHighlight: vi.fn(), + onClearHighlight: vi.fn(), + onCopy: vi.fn(), + onShare: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('AC1: Basic popover display', () => { + it('should display 5 color circles when verse selected', () => { + render(); + + const colorButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Apply')); + + expect(colorButtons).toHaveLength(5); + }); + + it('should render colors in correct order (yellow, green, blue, orange, pink)', () => { + render(); + + const applyButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Apply')); + + expect(applyButtons).toHaveLength(5); + applyButtons.forEach((btn) => { + const bgColor = btn.style.backgroundColor; + // Swatches render via theme tokens (var(--yv-*-30-dm)), with a hex fallback. + expect(bgColor).toMatch(/^(#[a-fA-F0-9]{6}|rgb\(.*\)|var\(--.*\))$/); + }); + }); + }); + + describe('AC2: Apply highlight', () => { + it('should call onHighlight when color circle clicked', () => { + const onHighlight = vi.fn(); + render(); + + const firstColorButton = screen + .getAllByRole('button') + .find((btn) => btn.getAttribute('aria-label')?.includes('Apply'))!; + + fireEvent.click(firstColorButton); + expect(onHighlight).toHaveBeenCalledWith(HIGHLIGHT_COLORS[0]); + }); + + it('should render popover with color buttons', () => { + render(); + + const dialog = screen.getByRole('dialog'); + expect(dialog).toBeTruthy(); + + const firstColorButton = screen + .getAllByRole('button') + .find((btn) => btn.getAttribute('aria-label')?.includes('Apply'))!; + + expect(firstColorButton).toBeTruthy(); + }); + }); + + describe('AC3: Copy action', () => { + it('should display copy button', () => { + render(); + const copyButton = screen.getByText('Copy'); + expect(copyButton).toBeTruthy(); + }); + + it('should call onCopy when copy button clicked', () => { + const onCopy = vi.fn(); + render(); + + const copyButton = screen.getByText('Copy'); + fireEvent.click(copyButton); + expect(onCopy).toHaveBeenCalled(); + }); + }); + + describe('AC4: Share action', () => { + it('should display share button', () => { + render(); + const shareButton = screen.getByText('Share'); + expect(shareButton).toBeTruthy(); + }); + + it('should call onShare when share button clicked', () => { + const onShare = vi.fn(); + render(); + + const shareButton = screen.getByText('Share'); + fireEvent.click(shareButton); + expect(onShare).toHaveBeenCalled(); + }); + }); + + describe('AC5: Single highlighted verse', () => { + it('should show 5 circles: 1 remove + 4 apply (only inactive colors)', () => { + const activeHighlights = new Set([HIGHLIGHT_COLORS[0]]); + const selectedVerses = [1]; + const highlightedVerses = { 1: HIGHLIGHT_COLORS[0] }; + + render( + , + ); + + const removeButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Clear')); + + const applyButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Apply')); + + expect(removeButtons).toHaveLength(1); + expect(applyButtons).toHaveLength(4); + }); + }); + + describe('AC5a: Ordering of circles', () => { + it('should show X circles leftmost, then apply circles for only inactive colors', () => { + const activeHighlights = new Set([HIGHLIGHT_COLORS[0], HIGHLIGHT_COLORS[2]]); + const selectedVerses = [1, 2]; + const highlightedVerses = { 1: HIGHLIGHT_COLORS[0], 2: HIGHLIGHT_COLORS[2] }; + + render( + , + ); + + const colorGroup = screen.getByRole('group', { name: 'Highlight colors' }); + const buttons = Array.from(colorGroup.querySelectorAll('button')); + + // First 2 should be clear (yellow, blue), then 3 apply (green, orange, pink - the inactive ones) + expect(buttons[0]?.getAttribute('aria-label')).toContain('Clear'); + expect(buttons[1]?.getAttribute('aria-label')).toContain('Clear'); + expect(buttons[2]?.getAttribute('aria-label')).toContain('Apply'); + expect(buttons[3]?.getAttribute('aria-label')).toContain('Apply'); + expect(buttons[4]?.getAttribute('aria-label')).toContain('Apply'); + }); + }); + + describe('AC6: Mixed selection (highlighted + unhighlighted)', () => { + it('should show all 5 apply colors when there are unhighlighted verses', () => { + const activeHighlights = new Set([HIGHLIGHT_COLORS[0]]); + const selectedVerses = [1, 2]; + const highlightedVerses = { 1: HIGHLIGHT_COLORS[0] }; // verse 2 is unhighlighted + + render( + , + ); + + const removeButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Clear')); + + const applyButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Apply')); + + // 1 yellow remove + all 5 apply (because verse 2 is unhighlighted) + expect(removeButtons).toHaveLength(1); + expect(applyButtons).toHaveLength(5); + }); + }); + + describe('AC7: Multiple different highlights', () => { + it('should show all 5 apply colors when multiple colors are active', () => { + const activeHighlights = new Set([HIGHLIGHT_COLORS[0], HIGHLIGHT_COLORS[1]]); + const selectedVerses = [1, 2]; + const highlightedVerses = { 1: HIGHLIGHT_COLORS[0], 2: HIGHLIGHT_COLORS[1] }; + + render( + , + ); + + const removeButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Clear')); + + const applyButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Apply')); + + // 2 remove (X) + all 5 apply (because multiple colors) + expect(removeButtons).toHaveLength(2); + expect(applyButtons).toHaveLength(5); + }); + + it('should call onClearHighlight with color when X circle clicked', () => { + const onClearHighlight = vi.fn(); + const activeHighlights = new Set([HIGHLIGHT_COLORS[0]]); + const selectedVerses = [1]; + const highlightedVerses = { 1: HIGHLIGHT_COLORS[0] }; + + render( + , + ); + + const removeButton = screen.getByRole('button', { name: /Clear highlight/ }); + fireEvent.click(removeButton); + expect(onClearHighlight).toHaveBeenCalledWith(HIGHLIGHT_COLORS[0]); + }); + + it('should call onClearHighlight with correct color for each button clicked', () => { + const onClearHighlight = vi.fn(); + const activeHighlights = new Set([HIGHLIGHT_COLORS[0], HIGHLIGHT_COLORS[1]]); + const selectedVerses = [1, 2]; + const highlightedVerses = { 1: HIGHLIGHT_COLORS[0], 2: HIGHLIGHT_COLORS[1] }; + + render( + , + ); + + const removeButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Clear')); + expect(removeButtons).toHaveLength(2); + + const firstBtn = removeButtons[0]; + const secondBtn = removeButtons[1]; + if (!firstBtn || !secondBtn) throw new Error('Expected 2 remove buttons'); + + fireEvent.click(firstBtn); + expect(onClearHighlight).toHaveBeenCalledWith(HIGHLIGHT_COLORS[0]); + + fireEvent.click(secondBtn); + expect(onClearHighlight).toHaveBeenCalledWith(HIGHLIGHT_COLORS[1]); + }); + }); + + describe('AC8 & AC8a: Dismiss logic on remove', () => { + it('should show remove buttons for active highlights', () => { + const activeHighlights = new Set([HIGHLIGHT_COLORS[0], HIGHLIGHT_COLORS[1]]); + const selectedVerses = [1, 2]; + const highlightedVerses = { 1: HIGHLIGHT_COLORS[0], 2: HIGHLIGHT_COLORS[1] }; + + render( + , + ); + + const removeButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Clear')); + + expect(removeButtons).toHaveLength(2); + }); + }); + + describe('Popover visibility', () => { + it('should not render content when open is false', () => { + render(); + + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('should render content when open is true', () => { + render(); + + expect(screen.getByRole('dialog')).toBeTruthy(); + }); + + it('should use Radix popover with portal', () => { + render(); + + const dialog = screen.getByRole('dialog'); + expect(dialog).toBeTruthy(); + }); + }); + + describe('Accessibility', () => { + it('should have proper ARIA labels for all buttons', () => { + const activeHighlights = new Set([HIGHLIGHT_COLORS[0]]); + const selectedVerses = [1]; + const highlightedVerses = { 1: HIGHLIGHT_COLORS[0] }; + + render( + , + ); + + const colorButtons = screen.getAllByRole('button').filter((btn) => { + const label = btn.getAttribute('aria-label'); + return label?.includes('highlight'); + }); + colorButtons.forEach((btn) => { + const label = btn.getAttribute('aria-label'); + expect(label).toMatch(/^(Apply|Clear) highlight$/); + }); + + expect(screen.getByText('Copy')).toBeTruthy(); + expect(screen.getByText('Share')).toBeTruthy(); + }); + + it('should have dialog role with aria-label', () => { + render(); + + const dialog = screen.getByRole('dialog'); + expect(dialog).toBeTruthy(); + expect(dialog.getAttribute('aria-label')).toBe('Verse actions'); + }); + + it('should have semantic color group', () => { + render(); + + const colorGroup = screen.getByRole('group', { name: 'Highlight colors' }); + expect(colorGroup).toBeTruthy(); + }); + }); + + describe('Styling', () => { + it('should have data-yv-sdk attribute for scoping', () => { + render(); + + const dialog = screen.getByRole('dialog'); + expect(dialog.getAttribute('data-yv-sdk')).not.toBeNull(); + }); + + it('should apply theme attribute', () => { + render(); + + const dialog = screen.getByRole('dialog'); + expect(dialog.getAttribute('data-yv-theme')).toBe('dark'); + }); + + it('should default to light theme', () => { + render(); + + const dialog = screen.getByRole('dialog'); + expect(dialog.getAttribute('data-yv-theme')).toBe('light'); + }); + }); + + describe('Edge cases', () => { + it('should handle empty active highlights', () => { + const activeHighlights = new Set(); + + render(); + + const applyButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Apply')); + + // Should still show 5 apply colors + expect(applyButtons).toHaveLength(5); + }); + + it('should handle all 5 colors highlighted', () => { + const activeHighlights = new Set(HIGHLIGHT_COLORS); + const selectedVerses = [1, 2, 3, 4, 5]; + const highlightedVerses = { + 1: HIGHLIGHT_COLORS[0], + 2: HIGHLIGHT_COLORS[1], + 3: HIGHLIGHT_COLORS[2], + 4: HIGHLIGHT_COLORS[3], + 5: HIGHLIGHT_COLORS[4], + }; + + render( + , + ); + + const removeButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Clear')); + + const applyButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Apply')); + + // 5 remove (X) + 0 apply (all colors already active) = 5 total + expect(removeButtons).toHaveLength(5); + expect(applyButtons).toHaveLength(0); + }); + + it('should show 3 verses with different highlights: Y(x), B(x), G(x), Y, G, B, O, P', () => { + const activeHighlights = new Set([ + HIGHLIGHT_COLORS[0], // yellow + HIGHLIGHT_COLORS[2], // blue + HIGHLIGHT_COLORS[1], // green + ]); + const selectedVerses = [1, 2, 3]; + const highlightedVerses = { + 1: HIGHLIGHT_COLORS[0], // yellow + 2: HIGHLIGHT_COLORS[2], // blue + 3: HIGHLIGHT_COLORS[1], // green + }; + + render( + , + ); + + const removeButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Clear')); + + const applyButtons = screen + .getAllByRole('button') + .filter((btn) => btn.getAttribute('aria-label')?.includes('Apply')); + + // 3 remove (X for Y, B, G) + all 5 apply (yellow, green, blue, orange, pink) = 8 total + expect(removeButtons).toHaveLength(3); + expect(applyButtons).toHaveLength(5); + }); + }); +}); diff --git a/packages/ui/src/components/verse-action-popover.tsx b/packages/ui/src/components/verse-action-popover.tsx new file mode 100644 index 00000000..ab6fe181 --- /dev/null +++ b/packages/ui/src/components/verse-action-popover.tsx @@ -0,0 +1,313 @@ +import { useEffect, useMemo, useRef, useState, type FC } from 'react'; +import * as PopoverPrimitive from '@radix-ui/react-popover'; +import { useTranslation } from 'react-i18next'; +import i18n from '@/i18n'; +import { cn } from '../lib/utils'; +import { BoxStackIcon } from './icons/box-stack'; +import { BoxArrowUpIcon } from './icons/box-arrow-up'; +import { XIcon } from './icons/x'; + +type Measurable = { getBoundingClientRect: () => DOMRect }; + +/** + * Highlight colors, as 6-digit lowercase hex (no `#`) so they map 1:1 onto the + * future API `highlight.color` field (/^[0-9a-f]{6}$/). Order is the canonical + * apply order: yellow, green, blue, orange, pink. Hardcoded to match the + * YouVersion iOS app exactly. + */ +export const HIGHLIGHT_COLORS = ['fffe00', '5dff79', '00d6ff', 'ffc66f', 'ff95ef'] as const; + +export type HighlightColor = (typeof HIGHLIGHT_COLORS)[number]; + +type VerseActionPopoverProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + activeHighlights: Set; + selectedVerses: number[]; + highlightedVerses: Record; + anchorElement?: HTMLElement | null; + /** + * The reader's scroll container. When provided, the bar docks to the edge of + * this element that the anchored verse scrolls out through, so the actions stay + * reachable instead of leaving with the verse. Omit for a purely anchored bar. + */ + scrollRoot?: HTMLElement | null; + onHighlight: (color: string) => void; + onClearHighlight: (color: string) => void; + onCopy: () => void; + onShare: () => void; + theme?: 'light' | 'dark'; +}; + +type ColorCircleProps = { + color: string; + showX: boolean; + label: string; + onClick: () => void; +}; + +function ColorCircle({ color, showX, label, onClick }: ColorCircleProps) { + return ( + + ); +} + +type ActionButtonProps = { + icon: React.ReactNode; + label: string; + onClick: () => void; +}; + +function ActionButton({ icon, label, onClick }: ActionButtonProps) { + return ( + + ); +} + +export const VerseActionPopover: FC = ({ + open, + onOpenChange, + activeHighlights, + selectedVerses, + highlightedVerses, + anchorElement, + scrollRoot, + onHighlight, + onClearHighlight, + onCopy, + onShare, + theme = 'light', +}) => { + const { t } = useTranslation(undefined, { i18n }); + + // When the anchored verse scrolls out of the container, dock the bar to the + // edge it exited through: scroll down (verse leaves the top) → dock top; scroll + // up (verse leaves the bottom) → dock bottom. `null` = anchored (verse visible, + // or docking disabled). The bar always passes through the visible/anchored state + // when reversing direction, so it never jumps directly top↔bottom. + const [dockEdge, setDockEdge] = useState<'top' | 'bottom' | null>(null); + useEffect(() => { + // Closing: leave dockEdge as-is so the frozen snapshot (below) animates out + // in place. The prior effect's cleanup already disconnected the observer. + if (!open) return; + if (!anchorElement || !scrollRoot || typeof IntersectionObserver === 'undefined') { + setDockEdge(null); + return; + } + // The just-tapped verse is on screen, so start anchored and let the observer + // flip us to docked only after the user scrolls it away. + setDockEdge(null); + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[entries.length - 1]; + if (!entry) return; + if (entry.isIntersecting) { + setDockEdge(null); + return; + } + // Off-screen: figure out which edge it exited through. rootBounds is + // populated because we pass an explicit root. + const rootTop = entry.rootBounds?.top ?? scrollRoot.getBoundingClientRect().top; + setDockEdge(entry.boundingClientRect.bottom <= rootTop ? 'top' : 'bottom'); + }, + { root: scrollRoot, threshold: 0 }, + ); + observer.observe(anchorElement); + return () => observer.disconnect(); + }, [open, anchorElement, scrollRoot]); + + const anchorRef = useMemo( + () => (anchorElement ? { current: anchorElement as Measurable } : undefined), + [anchorElement], + ); + + // A virtual anchor pinned to the top- or bottom-center of the scroll container, + // in viewport coordinates. Read live so it stays put as the reader scrolls. + const dockedRef = useMemo(() => { + if (!scrollRoot || !dockEdge) return undefined; + return { + current: { + getBoundingClientRect: () => { + const r = scrollRoot.getBoundingClientRect(); + const cx = r.left + r.width / 2; + const y = dockEdge === 'top' ? r.top : r.bottom; + return { + x: cx, + y, + left: cx, + right: cx, + top: y, + bottom: y, + width: 0, + height: 0, + toJSON: () => ({}), + } as DOMRect; + }, + }, + }; + }, [scrollRoot, dockEdge]); + + const docked = Boolean(dockedRef); + const virtualRef = docked ? dockedRef : anchorRef; + // Top-edge anchor → render below it (side bottom); bottom-edge anchor → render + // above it (side top). Both place the bar just inside the reader edge. + const dockedSide = dockEdge === 'top' ? 'bottom' : 'top'; + + const activeColors = HIGHLIGHT_COLORS.filter((c) => activeHighlights.has(c)); + const highlightedVerseCount = selectedVerses.filter((v) => highlightedVerses[v]).length; + const unHighlightedCount = selectedVerses.length - highlightedVerseCount; + const allColorsActive = activeHighlights.size === HIGHLIGHT_COLORS.length; + const showAllApplyColors = + !allColorsActive && (unHighlightedCount > 0 || activeHighlights.size > 1); + const colorsToApply = showAllApplyColors + ? HIGHLIGHT_COLORS + : HIGHLIGHT_COLORS.filter((c) => !activeHighlights.has(c)); + + // X (remove) circles come first, then apply circles in canonical order. + const colorCircles = [ + ...activeColors.map((color) => ({ color, showX: true, key: `${color}-clear` })), + ...colorsToApply.map((color) => ({ color, showX: false, key: `${color}-apply` })), + ]; + + // Snapshot of everything the Content renders. While open we keep it fresh; the + // moment `open` flips false (apply / outside-click) the parent clears the + // selection and anchor synchronously, so without this the still-animating bar + // would lose its anchor (jump to a fallback position) and flash the empty + // layout. Freezing the last snapshot lets it simply fade out where it was. + const dockSide: 'top' | 'bottom' = docked ? dockedSide : 'bottom'; + const live = { + virtualRef, + side: dockSide, + sideOffset: docked ? 24 : 20, + showCaret: !docked, + colorCircles, + }; + const frozenView = useRef(live); + if (open) frozenView.current = live; + const view = open ? live : frozenView.current; + + return ( + + + + { + // Tapping another verse modifies the selection — it should re-anchor + // the popover, not dismiss it. Only a tap truly outside the reader + // dismisses (which clears the selection via onOpenChange). + const target = event.detail.originalEvent.target as HTMLElement | null; + if (target?.closest('.yv-v[v]')) { + event.preventDefault(); + } + }} + side={view.side} + sideOffset={view.sideOffset} + align="center" + className={cn( + 'yv:bg-card yv:text-popover-foreground', + 'yv:rounded-full yv:drop-shadow-[0px_4.8432px_20px_rgba(0,0,0,0.19)]', + 'yv:px-4 yv:py-2', + 'yv:flex yv:items-center yv:gap-3', + 'yv:z-50 yv:outline-hidden', + 'yv:overflow-visible yv:relative', + 'yv:origin-(--radix-popover-content-transform-origin)', + 'yv:data-[state=open]:animate-in yv:data-[state=closed]:animate-out', + 'yv:data-[state=closed]:fade-out-0 yv:data-[state=open]:fade-in-0', + 'yv:data-[state=closed]:zoom-out-95 yv:data-[state=open]:zoom-in-95', + 'yv:data-[side=bottom]:slide-in-from-top-2', + 'yv:data-[side=top]:slide-in-from-bottom-2', + )} + style={ + { + '--tw-animate-duration': '180ms', + '--tw-animate-easing': 'cubic-bezier(0.16, 1, 0.3, 1)', + } as React.CSSProperties + } + > + {/* Caret — matches the card background in both themes, pointing at the + verse. Hidden when docked: the bar is no longer tied to a verse. */} + {view.showCaret && ( + + )} + +
+ {view.colorCircles.map(({ color, showX, key }) => ( + (showX ? onClearHighlight(color) : onHighlight(color))} + /> + ))} +
+ + {/* Separator */} +