feat(ui): host-owned verse actions on BibleReader - #307
Conversation
Three additive, non-breaking changes to BibleReader.Root so a host can own the verse-action UI. Every default is unchanged. - verseActions?: 'popover' | 'none' gates the built-in VerseActionPopover. - clearSelectionSignal?: number clears the selection from outside; a counter rather than a ref handle because Expo DOM props must be serializable. - BibleReaderVerseSelection gains reference and shareData; the highlight intent payload stays byte-identical via Omit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 6009f21 The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Verse actions move out of the Web SDK's in-WebView popover and into a native bottom sheet, matching Swift and Kotlin. The DOM component gets `verseActions="none"` on every platform but web; native holds the committed selection from `onVerseSelect` and clears it back over the bridge with `clearSelectionSignal`, a counter (only serializable props cross the Expo DOM bridge, so a ref handle is not an option). - `native/bible-verse-action-sheet.tsx` — presentational, not exported. Horizontally scrolling swatch tray under an SVG gradient fade at each end, with Copy and Share pinned outside the scroll area. - `lib/verse-action-swatches.ts` — port of the Web SDK's shipped ANY rule, layer-1 tested. - `NativeSheet` gains `modal`. The action sheet is non-modal so the passage behind stays tappable while a selection is built; that drops the backdrop, so sheets now draw an upward `boxShadow` (`SHEET_TOP_SHADOW`) to separate the top edge. - `onCopy` / `onShare` no longer cross the bridge — `shareData` rides in on `onVerseSelect` (Web SDK 2.5.0). The consumer-override-then-SDK- fallback contract is unchanged. Also exempts `@youversion/*` from the pnpm release-age cooldown, so a Web SDK enabling change is consumable the day it publishes. Requires @youversion/platform-react-ui 2.5.0, which is not yet published — pnpm-lock.yaml still resolves 2.4.0 and must be regenerated once youversion/platform-sdk-react#307 ships. See docs/adr/0015-native-verse-action-sheet.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lves Greptile P1 on #307: `reference` and `shareData` were a snapshot of `useBooks` / `useVersion` taken when the selection was made. A selection made before that metadata resolved left the host holding the USFM book code and a version-less copy/share payload until the user reselected — visible to any host on `verseActions="none"` rendering its own UI. `Content` now re-emits the live selection when the localized book title or version abbreviation changes. Both refs seed from the mount values, so the first effect run is never an emit, and the empty-selection guard keeps navigation (which clears first) from re-emitting a dead selection. `onVerseSelect` therefore can fire twice for one user action inside that race window, with the same `verses`. Documented on the prop, the `reference` field, and the changeset: the payload is the current state of the selection, not one event per action. Not gated on `verseActions="none"` on purpose — payload freshness that varies by mode is a worse trap than an extra call. Adds 4 cases: a re-emit for each of the two metadata sources (both fail without the fix), plus negative controls for no live selection and for a settled-metadata re-render. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Hey @Dustin-Kelley / @jaredhightower-youversion, this is ready for review. It makes BibleReader's verse actions host-owned so a React Native / Expo host can show its own native action sheet instead of the built-in web popover stacking on top of it. Or... explain like I'm 5:
|
Dustin-Kelley
left a comment
There was a problem hiding this comment.
Claude found that one docs issue but it's not blocking.
The built-in popover is the only trigger for handleHighlight / handleClearHighlight, so verseActions="none" - which stops it mounting - also stops onHighlightApply / onHighlightRemove from ever firing. The prop docs and the changeset claimed the opposite, which would have sent a host down a dead path: set "none", pass onHighlightApply, render its own swatches, and never hear a tap. A host in this mode owns `highlights` and paints by updating that prop. Docs only, plus a test that holds the contract honest. No behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The assertion was that neither highlight callback fires after selecting two verses - but selecting a verse never emits an intent in either mode, a swatch has to be clicked. The test passed with verseActions="popover" too, so it locked nothing. Now a differential: the popover arm clicks Apply and Clear and proves both callbacks fire, then the same props and script under 'none' assert they cannot. Verified by mutation - removing the `verseActions !== 'none'` guard fails the test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
YPE-2894 | Artifacts | Task
What problems was I solving
BibleReaderalways rendered its ownVerseActionPopoverover a selection. That is right for a web host, but wrong for a React Native / Expo host that presents a native bottom sheet for verse actions: the in-WebView popover stacks on top of the native sheet, so the user sees two competing action surfaces.A host can't just hide the popover with CSS, because three things were locked inside it:
refhandle doesn't work here — Expo DOM components only accept serializable props.onVerseSelectgave USFM codes (HEB), not a label a host can render (Hebrews 11:4), and the Copy / Share payload only existed inside the popover's button handlers.Shipped, a host can own the entire verse-action surface while the reader keeps doing selection, painting, and payload construction. Every default is unchanged — an existing consumer that passes nothing sees identical behavior, which is what the passing UI test suite holds in place.
What user-facing changes did I ship
BibleReader.RootpropverseActions?: 'popover' | 'none'.'popover'is the default and renders today's built-in popover.'none'renders no verse-action UI at all.BibleReader.RootpropclearSelectionSignal?: number. Any change to the value clears the live selection from outside the reader and emitsonVerseSelectwithverses: [].BibleReaderVerseSelectiongainsreference(localized display reference, no version abbreviation) andshareData(the sameBibleReaderShareDatathe popover's Copy / Share buttons produce;nullon clear).minorbump for@youversion/platform-react-ui, with the full consumer-facing contract including the re-emit semantics.No breaking changes.
verseActionsdefaults to'popover',clearSelectionSignalis inert when unset, and the two new payload fields are additive.How I implemented it
Frontend Changes
1. Opting out of the popover — bible-reader.tsx R1051
verseActionsthreads fromRootPropsthroughBibleReaderContextTypeintoContent, where the<VerseActionPopover>is wrapped in{verseActions !== 'none' && ...}. It does not mount at all rather than render hidden — two action surfaces would stack. Nothing else inContentbranches on it: selection, painting, andonVerseSelectare on the same path in both modes.One consequence worth reading before you use
'none':handleHighlight/handleClearHighlightare wired only into the popover, so not mounting it meansonHighlightApply/onHighlightRemovenever fire in this mode. That's intended — a host in'none'mode ownshighlightsand paints by updating that prop — but the first version of this PR documented the opposite. Caught by @Dustin-Kelley in review and corrected ine03fdf6across theverseActionsTSDoc, the two callback TSDocs, and the changeset, with a test that holds the contract.2. Making the payload useful to a host — buildVerseSelection R805
buildVerseSelectionnow also producesreference(via the existingbuildVerseReference, withversionAbbreviation: '') andshareData.referenceandshareData.referencedeliberately differ: copied text keeps the version (Hebrews 11:4 BSB), a UI label does not (Hebrews 11:4). Both are documented in TSDoc with a "do not 'fix' one to match the other" note.This required
buildSelectionShareDatato takeversesas a parameter instead of readingselectedVersesstate.buildVerseSelectionruns inside the click handler that sets the new selection, so reading state there would have built the payload for the previous selection. The two existing callers (handleCopy/handleShare) passselectedVersesexplicitly, so their behavior is unchanged.BibleReaderHighlightIntentwasBibleReaderVerseSelection & { color }; it's nowOmit<BibleReaderVerseSelection, 'reference' | 'shareData'> & { color }. An intent is addressed to the data layer, which needs identity and color only — no reason to widen it.3. Host-driven clearing — clearSelectionSignal effect R797
An effect on
clearSelectionSignalcalls the existingcloseAndClearSelection— the same path an outside click runs, so there's no second clear implementation to drift. Two details:lastClearSelectionSignalRefseeds from the mount value, so mounting is never a clear and a re-render with an unchanged signal is a no-op.closeAndClearSelectionwas also extended to emitreference: ''/shareData: nullalongsideverses: [].4. Stale-metadata re-emit (Greptile P1 fix) — metadata effect R842
referenceandshareDataare a snapshot ofuseBooks/useVersion, which can resolve after a verse is selected. In that window the payload carries the raw USFM code (HEB 11:4) and no version abbreviation — and withverseActions="none"nothing else would ever refresh it, so a host would display the fallback until the user re-selected.A second effect watches
bookData?.titleandversion?.localized_abbreviationand re-emits the live selection with the sameversesand refreshed fields when either lands. Guards:The consequence for hosts is documented on
onVerseSelectand in the changeset: treat the payload as the current state of the selection, not as one event per user action.Tests
bible-reader-controlled.test.tsx — 17 new cases in three describes:
BibleReader verseActions— popover renders by default, renders for an explicit'popover', renders nothing for'none'while selection still works, and emits no highlight intents in'none'mode.BibleReader selection payload - reference and shareData— localized reference without the version abbreviation, USFM fallback beforeuseBooksresolves, re-emit when the book title resolves, re-emit when the version abbreviation resolves, no re-emit with no selection live, no re-emit on a settled re-render (the default path stays quiet),shareDatamatching whatonCopyproduces, and''/nullon clear.BibleReader clearSelectionSignal— clears and emitsverses: [], also closes the built-in popover, does not clear on mount, does not clear on an unchanged re-render, inert when never passed.Deviations from the plan
No plan file found in the task directory — this task carried only
ticket.md, so there is no plan-vs-implementation delta to report.Worth flagging for reviewers who saw the earlier state of this PR, two follow-up commits landed from review:
86effb6resolves the Greptile P1 "Selection metadata remains stale" comment — section 4 above, plus its four tests.e03fdf6corrects theverseActions="none"highlight-intent contract after @Dustin-Kelley caught the docs claiming intents still fire when the popover (their only trigger) isn't mounted. Docs and changeset only, plus one test. No behavior change.How to verify it
Manual Testing
verseActions="none": verses still select and paint, no popover appears anywhere,onVerseSelectfires with populatedreferenceandshareData.verseActions="none", incrementclearSelectionSignalfrom host UI: selection clears andonVerseSelectfires withverses: [],reference: '',shareData: null.clearSelectionSignal: nothing is cleared on mount.onVerseSelectarrives with the localizedHebrews 11:4and fullshareData.referencehas no version abbreviation whileshareData.referencedoes.Automated Tests
git fetch origin ype-2894-add-highlights-to-the-react-native-expo-sdk git checkout ype-2894-add-highlights-to-the-react-native-expo-sdk pnpm install pnpm typecheck pnpm --filter @youversion/platform-react-ui testCurrent state on this branch: 377 tests passing across 28 files in
@youversion/platform-react-ui, and all 8 CI checks green one03fdf6.Description for the changelog
BibleReadercan now hand verse actions to the host viaverseActions="none", withclearSelectionSignalfor outside-in clearing andreference/shareDataon theonVerseSelectpayload. All defaults unchanged.Greptile Summary
Adds host-owned verse-action support to
BibleReader.verseActions="none"to suppress the built-in action popover while preserving verse selection.clearSelectionSignalfor host-driven selection clearing.referenceand reusableshareData.Confidence Score: 5/5
The PR appears safe to merge.
The previously reported stale-selection-metadata failure is resolved: metadata changes rebuild and re-emit a live selection, while navigation and host-driven clearing empty the selection ref before that refresh effect can emit.
Important Files Changed
Sequence Diagram
Reviews (5): Last reviewed commit: "test(ui): make the verseActions="none" i..." | Re-trigger Greptile