diff --git a/.changeset/all-humans-leave.md b/.changeset/all-humans-leave.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/all-humans-leave.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/bitter-pigs-type.md b/.changeset/bitter-pigs-type.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/bitter-pigs-type.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/bright-shadows-plan.md b/.changeset/bright-shadows-plan.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/bright-shadows-plan.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/calm-browsers-test.md b/.changeset/calm-browsers-test.md new file mode 100644 index 00000000..c4bef8ff --- /dev/null +++ b/.changeset/calm-browsers-test.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-ui': patch +--- + +Preserve SDK spacing in Safari inside Shadow DOM and document roots by scoping the host CSS reset and inlining the fixed Tailwind spacing scale. diff --git a/.changeset/cool-settings-wait.md b/.changeset/cool-settings-wait.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/cool-settings-wait.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/flat-shadows-report.md b/.changeset/flat-shadows-report.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/flat-shadows-report.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/friendly-verses-scroll.md b/.changeset/friendly-verses-scroll.md new file mode 100644 index 00000000..42d2d7f3 --- /dev/null +++ b/.changeset/friendly-verses-scroll.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-ui': patch +--- + +Keep verse actions open when touch selection moves to another verse, restore focus for initially anchored or conditionally mounted verse actions, and keep reader settings scrollable in constrained popovers. Clarify the Shadow DOM prototype's document-owned font and root-sizing dependencies without expanding its rollout scope. diff --git a/.changeset/full-flies-worry.md b/.changeset/full-flies-worry.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/full-flies-worry.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/fuzzy-spiders-check.md b/.changeset/fuzzy-spiders-check.md new file mode 100644 index 00000000..df446eef --- /dev/null +++ b/.changeset/fuzzy-spiders-check.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-ui': patch +--- + +Audit the compiled Shadow DOM stylesheet custom-property contract and fail builds on unexplained ambient references. diff --git a/.changeset/leaf-shadows-wait.md b/.changeset/leaf-shadows-wait.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/leaf-shadows-wait.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/little-fans-burn.md b/.changeset/little-fans-burn.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/little-fans-burn.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/prototype-shadow-dom-isolation.md b/.changeset/prototype-shadow-dom-isolation.md new file mode 100644 index 00000000..1bd59695 --- /dev/null +++ b/.changeset/prototype-shadow-dom-isolation.md @@ -0,0 +1,7 @@ +--- +'@youversion/platform-core': major +'@youversion/platform-react-hooks': major +'@youversion/platform-react-ui': major +--- + +Prototype automatic Shadow DOM style isolation on `YouVersionAuthButton`, plus a shadow-local native top-layer strategy for isolated Popovers. The spike also constrains Popovers to Radix's available collision height. diff --git a/.changeset/prove-production-shadow-overlays.md b/.changeset/prove-production-shadow-overlays.md new file mode 100644 index 00000000..2e0a581b --- /dev/null +++ b/.changeset/prove-production-shadow-overlays.md @@ -0,0 +1,4 @@ +--- +--- + +Prove nested and concurrent overlays through the production ShadowRootHost seam (YPE-5355). diff --git a/.changeset/quiet-dialogs-return.md b/.changeset/quiet-dialogs-return.md new file mode 100644 index 00000000..3e8a0f49 --- /dev/null +++ b/.changeset/quiet-dialogs-return.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-ui': patch +--- + +Fix focus restoration for opt-in Shadow DOM dialogs by preserving the original opener across a close/reopen during exit, while ignoring disconnected targets and targets moved out of their captured root. diff --git a/.changeset/reader-shadows-wait.md b/.changeset/reader-shadows-wait.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/reader-shadows-wait.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/safe-shadows-fallback.md b/.changeset/safe-shadows-fallback.md new file mode 100644 index 00000000..dee4ef12 --- /dev/null +++ b/.changeset/safe-shadows-fallback.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-ui': patch +--- + +Keep isolated UI content rendered with local styles when constructable stylesheet setup fails. diff --git a/.changeset/salty-lizards-dance.md b/.changeset/salty-lizards-dance.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/salty-lizards-dance.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/scripture-shadows-wait.md b/.changeset/scripture-shadows-wait.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/scripture-shadows-wait.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/shadow-aware-verse-actions.md b/.changeset/shadow-aware-verse-actions.md new file mode 100644 index 00000000..cd961cbd --- /dev/null +++ b/.changeset/shadow-aware-verse-actions.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-ui': patch +--- + +Keep verse action popovers in their component's shadow-local portal and restore focus after dismissal. diff --git a/.changeset/six-shrimps-stare.md b/.changeset/six-shrimps-stare.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/six-shrimps-stare.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/tidy-shadow-events.md b/.changeset/tidy-shadow-events.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/tidy-shadow-events.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/tidy-shadows-reset.md b/.changeset/tidy-shadows-reset.md new file mode 100644 index 00000000..3f033cda --- /dev/null +++ b/.changeset/tidy-shadows-reset.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-ui': patch +--- + +Harden Shadow DOM style isolation so text direction is the only intentionally inherited CSS property and host custom properties cannot alter known SDK spacing or radius values. Document-root font sizing still affects the prototype's rem-based dimensions. diff --git a/.changeset/validate-shadow-dialog.md b/.changeset/validate-shadow-dialog.md new file mode 100644 index 00000000..28f500ca --- /dev/null +++ b/.changeset/validate-shadow-dialog.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-ui': patch +--- + +Validate shadow-local top-layer Dialog behavior, including focus containment and modal background inertness. diff --git a/.github/scripts/major-release-signoff.test.sh b/.github/scripts/major-release-signoff.test.sh index 90cf61f7..c2ceb683 100644 --- a/.github/scripts/major-release-signoff.test.sh +++ b/.github/scripts/major-release-signoff.test.sh @@ -30,10 +30,58 @@ extract_step() { } extract_step "Resolve PR context" > "$TMP/context.sh" +extract_step "Restore release tooling from the base branch" > "$TMP/restore-tooling.sh" +extract_step "Compute release preview" > "$TMP/preview.sh" extract_step "Decide whether a signoff is required" > "$TMP/decision.sh" extract_step "Regenerate and verify release contents" > "$TMP/verify.sh" extract_step "Post an unresolved status" > "$TMP/unresolved.sh" +TOOLING_SOURCE="$TMP/tooling-source" +TOOLING_WORK="$TMP/tooling-work" +git init --quiet "$TOOLING_SOURCE" +git -C "$TOOLING_SOURCE" config commit.gpgsign false +git -C "$TOOLING_SOURCE" config user.name test +git -C "$TOOLING_SOURCE" config user.email test@example.com +mkdir -p "$TOOLING_SOURCE/scripts" "$TOOLING_SOURCE/.changeset" \ + "$TOOLING_SOURCE/packages/ui" "$TOOLING_SOURCE/examples/demo" "$TOOLING_SOURCE/tools/config" +for file in scripts/preview-release.mjs package.json pnpm-lock.yaml \ + pnpm-workspace.yaml .changeset/config.json .npmrc packages/ui/package.json \ + examples/demo/package.json tools/config/package.json; do + printf 'main %s\n' "$file" > "$TOOLING_SOURCE/$file" +done +git -C "$TOOLING_SOURCE" add -A +git -C "$TOOLING_SOURCE" commit --quiet -m main +git -C "$TOOLING_SOURCE" branch -M main +git -C "$TOOLING_SOURCE" switch --quiet -c journey +mkdir -p "$TOOLING_SOURCE/packages/pr-only" +printf 'journey-only importer\n' > "$TOOLING_SOURCE/packages/pr-only/package.json" +for file in scripts/preview-release.mjs package.json pnpm-lock.yaml \ + pnpm-workspace.yaml .changeset/config.json packages/ui/package.json; do + printf 'journey %s\n' "$file" > "$TOOLING_SOURCE/$file" +done +rm "$TOOLING_SOURCE/.npmrc" +printf 'journey pnpm hook\n' > "$TOOLING_SOURCE/.pnpmfile.cjs" +git -C "$TOOLING_SOURCE" add -A +git -C "$TOOLING_SOURCE" commit --quiet -m journey +TOOLING_HEAD=$(git -C "$TOOLING_SOURCE" rev-parse HEAD) +git -C "$TOOLING_SOURCE" switch --quiet main +git clone --quiet "$TOOLING_SOURCE" "$TOOLING_WORK" +git -C "$TOOLING_WORK" checkout --quiet "$TOOLING_HEAD" + +if (cd "$TOOLING_WORK" && bash "$TMP/restore-tooling.sh") >/dev/null 2>&1 && + [ "$(git -C "$TOOLING_WORK" rev-parse HEAD)" = "$TOOLING_HEAD" ] && + [ "$(cat "$TOOLING_WORK/package.json")" = 'main package.json' ] && + [ "$(cat "$TOOLING_WORK/pnpm-lock.yaml")" = 'main pnpm-lock.yaml' ] && + [ "$(cat "$TOOLING_WORK/packages/ui/package.json")" = 'main packages/ui/package.json' ] && + [ "$(cat "$TOOLING_WORK/.npmrc")" = 'main .npmrc' ] && + [ ! -e "$TOOLING_WORK/packages/pr-only/package.json" ] && + [ ! -e "$TOOLING_WORK/.pnpmfile.cjs" ]; then + pass "restores the complete release dependency graph from trusted main" +else + fail "restores the complete release dependency graph from trusted main" \ + "expected main-owned workspace manifests and install inputs with PR-only importers removed" +fi + mkdir "$TMP/bin" # The context step backs off between retries; real delays would add ~9s per error case. cat > "$TMP/bin/sleep" <<'EOF' @@ -376,7 +424,7 @@ run_decision_case "failed ordinary previews remain blocked, not major" \ 'blocked=release preview did not succeed (failure)' false false skipped '' failure '' extract_step "Restore release tooling from the base branch" > "$TMP/restore.sh" -PREVIEW_REPO="$TMP/preview-repo" +PREVIEW_REPO="$TMP/restore-preview-repo" git init --quiet --initial-branch=main "$PREVIEW_REPO" git -C "$PREVIEW_REPO" config commit.gpgsign false git -C "$PREVIEW_REPO" config user.name test @@ -440,6 +488,106 @@ else fail "generated release identity always stays out of the ordinary preview" "preview does not use generated release identity" fi +PREVIEW_REMOTE="$TMP/preview-remote.git" +PREVIEW_REPO="$TMP/preview-repo" +git init --quiet --bare "$PREVIEW_REMOTE" +git init --quiet "$PREVIEW_REPO" +git -C "$PREVIEW_REPO" config commit.gpgsign false +git -C "$PREVIEW_REPO" config user.name test +git -C "$PREVIEW_REPO" config user.email test@example.com +git -C "$PREVIEW_REPO" remote add origin "$PREVIEW_REMOTE" +mkdir -p "$PREVIEW_REPO/scripts" "$PREVIEW_REPO/.changeset" \ + "$PREVIEW_REPO/packages/core" "$PREVIEW_REPO/packages/hooks" "$PREVIEW_REPO/packages/ui" +cp "$ROOT/scripts/preview-release.mjs" "$PREVIEW_REPO/scripts/preview-release.mjs" +cp "$ROOT/.changeset/config.json" "$PREVIEW_REPO/.changeset/config.json" +cat > "$PREVIEW_REPO/package.json" <<'EOF' +{"name":"preview-fixture","private":true,"packageManager":"pnpm@11.10.0"} +EOF +cat > "$PREVIEW_REPO/pnpm-workspace.yaml" <<'EOF' +packages: + - "packages/*" +EOF +for package in core hooks ui; do + case "$package" in + core) name='@youversion/platform-core' ;; + hooks) name='@youversion/platform-react-hooks' ;; + ui) name='@youversion/platform-react-ui' ;; + esac + printf '{"name":"%s","version":"1.0.0"}\n' "$name" > "$PREVIEW_REPO/packages/$package/package.json" +done +git -C "$PREVIEW_REPO" add -A +git -C "$PREVIEW_REPO" commit --quiet -m main +git -C "$PREVIEW_REPO" branch -M main +git -C "$PREVIEW_REPO" push --quiet origin main + +git -C "$PREVIEW_REPO" switch --quiet -c target +cat > "$PREVIEW_REPO/.changeset/target-major.md" <<'EOF' +--- +"@youversion/platform-core": major +--- + +Major change inherited from the target branch. +EOF +git -C "$PREVIEW_REPO" add -A +git -C "$PREVIEW_REPO" commit --quiet -m 'target major' +git -C "$PREVIEW_REPO" push --quiet origin target +PREVIEW_BASE_SHA=$(git -C "$PREVIEW_REPO" rev-parse HEAD) + +git -C "$PREVIEW_REPO" switch --quiet -c pr +printf 'non-major PR change\n' > "$PREVIEW_REPO/README.md" +git -C "$PREVIEW_REPO" add README.md +git -C "$PREVIEW_REPO" commit --quiet -m 'non-major PR change' +git -C "$PREVIEW_REPO" push --quiet origin pr +ln -s "$ROOT/node_modules" "$PREVIEW_REPO/node_modules" +PREVIEW_BIN="$TMP/preview-bin" +mkdir "$PREVIEW_BIN" +cat > "$PREVIEW_BIN/pnpm" < "\$output" +EOF +chmod +x "$PREVIEW_BIN/pnpm" + +run_preview_case() { + local name="$1" expected_introduced="$2" expected_output="$3" + local output="$TMP/preview-output" + local error="$TMP/preview-error" + local preview="$PREVIEW_REPO/preview.json" + local head_sha + head_sha=$(git -C "$PREVIEW_REPO" rev-parse HEAD) + : > "$output" + rm -f "$preview" + if (cd "$PREVIEW_REPO" && \ + PATH="$PREVIEW_BIN:$PATH" BASE_SHA="$PREVIEW_BASE_SHA" HEAD_SHA="$head_sha" \ + GITHUB_OUTPUT="$output" \ + bash "$TMP/preview.sh") >/dev/null 2> "$error" && + jq -e ".introduced_major == $expected_introduced" "$preview" >/dev/null && + grep -Fxq "is_major=$expected_output" "$output"; then + pass "$name" + else + fail "$name" \ + "expected introduced_major=$expected_introduced and is_major=$expected_output; preview: $(cat "$preview" 2>/dev/null || true); output: $(tr '\n' ' ' < "$output"); error: $(tr '\n' ' ' < "$error")" + fi +} + +run_preview_case "stacked PRs ignore a major inherited from their target branch" false 0 + +cat > "$PREVIEW_REPO/.changeset/pr-major.md" <<'EOF' +--- +"@youversion/platform-react-ui": major +--- + +Major change introduced by the pull request. +EOF +git -C "$PREVIEW_REPO" add .changeset/pr-major.md +git -C "$PREVIEW_REPO" commit --quiet -m 'PR major' +git -C "$PREVIEW_REPO" push --quiet origin pr +run_preview_case "stacked PRs require signoff for a newly introduced major" true 1 + if grep -Fq 'Generated release PR; major signoff is enforced on source PRs.' "$WORKFLOW"; then pass "generated releases publish an explicit lifecycle-aware success" else diff --git a/.github/workflows/major-release-signoff.yml b/.github/workflows/major-release-signoff.yml index bf3e70d3..69387deb 100644 --- a/.github/workflows/major-release-signoff.yml +++ b/.github/workflows/major-release-signoff.yml @@ -269,9 +269,9 @@ jobs: - name: Restore release tooling from the base branch # The PR supplies changeset *data* only. The detector that reads it, the dependency - # set it runs under, and the changesets config all come from `main`, so a branch - # cannot rewrite the check that gates it. This runs before pnpm is set up, since - # `pnpm/action-setup` reads `packageManager` out of package.json. + # graph it runs under, and the changesets config all come from trusted `main`, so a + # branch cannot rewrite the check that gates it. This runs before pnpm is set up, + # since `pnpm/action-setup` reads `packageManager` out of package.json. run: | set -euo pipefail git fetch --no-tags --force origin main:refs/heads/main @@ -279,6 +279,14 @@ jobs: # under, and the changesets config have to agree, so restoring some from main and # taking the rest from the PR gives a detector without its own dependencies. if git cat-file -e main:scripts/preview-release.mjs 2>/dev/null; then + # Frozen workspace installs validate every importer. Remove PR-only importers, + # then restore every manifest selected by main's workspace configuration so its + # lockfile and dependency graph remain internally consistent. + git ls-files | + grep -E '^(packages|examples|tools)/[^/]+/package[.]json$' | + while IFS= read -r f; do + git cat-file -e "main:$f" 2>/dev/null || rm -f "$f" + done for f in scripts/preview-release.mjs package.json pnpm-lock.yaml \ pnpm-workspace.yaml .changeset/config.json; do git cat-file -e "main:$f" 2>/dev/null && git checkout main -- "$f" @@ -318,6 +326,7 @@ jobs: - name: Compute release preview id: preview env: + BASE_SHA: ${{ needs.context.outputs.base_sha }} HEAD_SHA: ${{ needs.context.outputs.head_sha }} run: | set -euo pipefail @@ -325,10 +334,11 @@ jobs: # "Failed to find where HEAD diverged from main" if it is missing. A PR checkout # has only origin/main, so create the local branch too. git fetch --no-tags --force origin main:refs/heads/main - # Merge-base, not the PR's base sha: on a synchronize event the base - # branch may have moved, and diffing against its tip would attribute - # someone else's changesets to this PR. - BASE=$(git merge-base main "$HEAD_SHA") + git fetch --no-tags --force origin "$BASE_SHA" + # Compare from the common ancestor of the immutable PR endpoints. This excludes + # major changesets inherited from a stacked PR's base without attributing later + # movement on that base branch to the PR being evaluated. + BASE=$(git merge-base "$BASE_SHA" "$HEAD_SHA") node scripts/preview-release.mjs --base "$BASE" --head "$HEAD_SHA" > preview.json cat preview.json # `introduced_major`, not `is_major`: a major already pending on main diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index 56e947c3..3f1b5a71 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -39,3 +39,37 @@ jobs: YVP_APP_KEY: ${{ secrets.YVP_APP_KEY }} STORYBOOK_YOUVERSION_API_HOST: ${{ secrets.YVP_API_HOST }} STORYBOOK_YOUVERSION_APP_KEY: ${{ secrets.STORYBOOK_YOUVERSION_APP_KEY }} + + shadow-browser-tests: + name: Shadow DOM (${{ matrix.browser }}) + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.56.1-noble + strategy: + fail-fast: false + matrix: + browser: [firefox, webkit] + env: + VITEST_BROWSER: ${{ matrix.browser }} + STORYBOOK_YOUVERSION_APP_KEY: ${{ secrets.STORYBOOK_YOUVERSION_APP_KEY }} + STORYBOOK_AUTH_REDIRECT_URL: ${{ secrets.STORYBOOK_AUTH_REDIRECT_URL }} + steps: + - uses: actions/checkout@v5 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build packages + run: pnpm build + + - name: Run Shadow DOM browser tests + run: cd packages/ui && pnpm run test:shadow-browser + env: + HOME: /root diff --git a/.size-limit.js b/.size-limit.js index bc82651c..51f7958b 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -49,7 +49,7 @@ export default [ name: 'ui / Separator only', path: 'packages/ui/dist/index.js', import: '{ Separator }', - limit: '19 KB', + limit: '21.7 KB', ignore: ['react', 'react-dom', 'react/jsx-runtime', '@tanstack/react-query'], }, { diff --git a/docs/adr/0007-prototype-shadow-dom-style-isolation.md b/docs/adr/0007-prototype-shadow-dom-style-isolation.md new file mode 100644 index 00000000..6fe3a8e7 --- /dev/null +++ b/docs/adr/0007-prototype-shadow-dom-style-isolation.md @@ -0,0 +1,189 @@ +# ADR 0007: Prototype automatic Shadow DOM style isolation + +Status: Accepted for coordinated rollout planning; production expansion is not yet shipped + +YPE-5356 accepts the architecture for a coordinated major-version rollout +across the compatible public UI boundary defined in the +[production rollout policy](../shadow-dom-rollout-policy.md). Implementation is +split into dependency-ordered component groups, but the package must not publish +a partial boundary. The production rollout policy maintains the current +stable-package and integration-branch boundary inventory while the remaining +rollout groups and coordinated release are pending. + +The automatic boundary belongs to the SDK-owned top-level component instance. +Compound members and SDK components composed inside another isolated SDK +component reuse their owning boundary instead of creating accidental nested +roots. Excluded exports and their reasons are defined in the rollout policy. + +Host applications can apply unlayered global CSS, including Tailwind preflight, +that outranks the UI package's layered styles. Resets, stronger selectors, +`!important`, cascade layers, and `@scope` remain part of the host document's +cascade and cannot prevent outside selectors from matching SDK internals. The +prototype therefore uses Shadow DOM as the browser-enforced style boundary. + +## Decision for the prototype + +`YouVersionAuthButton` automatically creates an open shadow root and renders its +existing implementation into it through a React portal. Consumers continue to +use the same component API; they do not need to discover or enable isolation. +The SDK's compiled Tailwind component rules are installed inside the root, the light-DOM host +receives a protected box reset, and an internal wrapper resets inherited visual +properties. + +Writing direction is the only intentional inherited CSS property: both reset +boundaries explicitly preserve `direction`, while `all: initial` restores +horizontal writing, mixed text orientation, SDK typography, and other visual +properties. Vertical host writing modes and inherited host typography are unsupported. +This is selector and inheritance isolation, not independent document sizing: +the prototype retains `rem` units, so the owning document's root font size still +scales SDK text, spacing, and controls. That sizing input is accepted for the +prototype; it is not reset by a shadow boundary. +Known ambient custom-property dependencies are closed by using SDK-owned +`--yv-spacing` and `--yv-radius` values and by defining a local `--spacing` +compatibility alias for `tw-animate-css`. The completed YPE-5400 inventory, +exact runtime-input exceptions, and compiled-CSS prevention guard are documented +in the rollout plan. + +Constructable stylesheets are cached per owning `Document`, because a sheet from +the top-level document cannot be adopted into a same-origin iframe's shadow +root. Environments without constructable stylesheets receive a ` + ))} + +
+

Automatic Shadow DOM isolation POC

+

+ This branch automatically isolates only YouVersionAuthButton. The plain host + controls are positive witnesses: they should look broken when an attack is active, while + the SDK button should remain stable. The font-face option demonstrates a known Shadow DOM + limitation. +

+ +
+ Hostile stylesheet vectors + {HOSTILE_VECTORS.map((vector) => ( +
+ + + {vector.example} + +
+ ))} +
+
+ +
+
+

LIGHT DOM — SHOULD BE AFFECTED

+
+ +

Plain host text for inherited-property attacks.

+
+ Host-box witness — this should disappear during the host attack. +
+
+ Pseudo-element witness — generated content should appear above this text. +
+

+ Host text requesting Inter for the font-face collision. +

+
+
+ +
+

SDK POC — SHOULD RESIST

+
+ console.error('Auth error:', error)} + /> +
+

+ Other SDK components are intentionally absent: automatic isolation has not been rolled + out to them on this POC branch. +

+
+
+ + ); +} diff --git a/packages/ui/.storybook/vitest.setup.ts b/packages/ui/.storybook/vitest.setup.ts index c45f9760..ef35f1e5 100644 --- a/packages/ui/.storybook/vitest.setup.ts +++ b/packages/ui/.storybook/vitest.setup.ts @@ -1,5 +1,6 @@ import { setProjectAnnotations } from '@storybook/react-vite'; import { beforeEach } from 'vitest'; +import { isExpectedFontStylesheetEvent } from '../src/test/storybook-font-stylesheet'; import * as previewAnnotations from './preview'; const _annotations = setProjectAnnotations([previewAnnotations]); @@ -16,6 +17,16 @@ Object.defineProperty(navigator, 'language', { configurable: true, }); +// React's precedence stylesheet resource rejects with the browser's generic +// load Event when the optional Fonts API sheet is unavailable. Firefox exposes +// that expected asset failure as an unhandled rejection; keep only that known +// request from masking the story assertions. +globalThis.addEventListener('unhandledrejection', (event) => { + if (event.reason instanceof Event && isExpectedFontStylesheetEvent(event.reason)) { + event.preventDefault(); + } +}); + beforeEach(() => { localStorage?.clear?.(); sessionStorage?.clear?.(); diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md index d5ef6c1f..a6a1823a 100644 --- a/packages/ui/AGENTS.md +++ b/packages/ui/AGENTS.md @@ -20,7 +20,17 @@ Keep this file brief. Put task-specific guidance behind a pointer. **React 19 ` + +

Consumer child

+ + +
+ + ), + play: async ({ canvasElement }) => { + const root = await waitForShadowRoot(canvasElement); + const body = root.querySelector( + '[data-yv-shadow-content-wrapper] > [data-yv-sdk]', + ); + const consumerChild = root.querySelector('[data-testid="consumer-reader-child"]'); + const renderer = await waitFor( + () => { + const element = root.querySelector('[data-slot="yv-bible-renderer"]'); + if (!element) throw new Error('reader scripture not mounted'); + return element; + }, + { timeout: 10000 }, + ); + const scroller = root.querySelector('main'); + const toolbar = root.querySelector('section'); + if (!body || !consumerChild || !scroller || !toolbar) { + throw new Error('reader layout evidence not rendered'); + } + + await expect(canvasElement.querySelector('[data-testid="consumer-reader-child"]')).toBeNull(); + await expect(body).toHaveAttribute('data-yv-theme', 'dark'); + await expect(body).toHaveAttribute('dir', 'rtl'); + await expect(renderer).toHaveAttribute('dir', 'ltr'); + await expect(getComputedStyle(renderer).fontFamily).toContain('Inter'); + await expect(getComputedStyle(renderer).fontSize).toBe('18px'); + await expect(getComputedStyle(scroller).overflowY).toBe('auto'); + await expect(getComputedStyle(consumerChild).color).not.toBe('rgb(255, 0, 0)'); + await expect(consumerChild.getBoundingClientRect().width).toBeLessThanOrEqual(390); + await expect(toolbar.getBoundingClientRect().width).toBeLessThanOrEqual(390); + }, +}; + +async function openReaderHighlightAuthDialog(canvasElement: HTMLElement) { + const root = await waitForShadowRoot(canvasElement); + const verse = await waitFor(() => { + const element = root.querySelector('.yv-v[v="1"]'); + if (!element) throw new Error('highlight-auth verse not rendered'); + return element; + }); + await userEvent.click(verse); + const overlay = await waitFor(() => { + const element = root.querySelector('[data-yv-shadow-local-overlay]'); + if (!element) throw new Error('reader auth overlay not mounted'); + return element; + }); + const overlays = within(overlay); + const colors = await overlays.findByRole('group', { name: 'Highlight colors' }); + const apply = Array.from(colors.querySelectorAll('button')).find((button) => + button.getAttribute('aria-label')?.includes('Apply'), + ); + if (!apply) throw new Error('highlight apply control not rendered'); + await userEvent.click(apply); + return { apply, overlays, root }; +} + +export const SignedOutHighlightAuthOwnership: Story = { + tags: ['integration', 'shadow-dom', 'cross-browser'], + args: { + defaultVersionId: 111, + defaultBook: 'JHN', + defaultChapter: '1', + }, + render: (args) => ( +
+ + + +
+ ), + play: async ({ canvasElement }) => { + const { apply, overlays, root } = await openReaderHighlightAuthDialog(canvasElement); + const yes = await overlays.findByRole('button', { name: 'Yes Please' }); + const dialog = yes.closest('[role="dialog"]'); + if (!dialog) throw new Error('sign-in dialog not rendered'); + await expect(dialog.getRootNode()).toBe(root); + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(overlays.queryByRole('button', { name: 'Yes Please' })).toBeNull()); + // The shared local overlay stays mounted because the verse-actions dialog + // remains open underneath the dismissed auth dialog. + await expect(overlays.getByRole('dialog', { name: 'Verse actions' })).toBeInTheDocument(); + await expect(root.activeElement).toBe(apply); + }, +}; + +function SignedInPermissionReader(args: BibleReaderRootProps) { + const [userInfo] = useState( + () => + new YouVersionUserInfo({ + id: 'mock-user-id', + name: 'Test User', + email: 'test@example.com', + }), + ); + + return ( + +
+ + + + +
+
+ ); +} + +export const SignedInHighlightPermissionOwnership: Story = { + tags: ['integration', 'shadow-dom', 'cross-browser'], + args: { + defaultVersionId: 111, + defaultBook: 'JHN', + defaultChapter: '1', + }, + parameters: { + msw: { + handlers: [ + http.get('*/v1/highlights', () => HttpResponse.json({ data: [], next_page_token: null })), + ...globalHandlers, + ], + }, + }, + render: (args) => , + play: async ({ canvasElement }) => { + await setupAuthenticatedUser(); + YouVersionPlatformConfiguration.clearGrantedPermissions(); + const { root: readerRoot, reader } = await getReaderStory(canvasElement); + const avatarTrigger = await reader.findByTestId('user-menu-trigger'); + const avatar = avatarTrigger.querySelector('[data-slot="avatar"]'); + await expect(avatar).toBeInTheDocument(); + await expect(avatar?.getRootNode()).toBe(readerRoot); + await expect(readerRoot.querySelector('[data-yv-shadow-host]')).toBeNull(); + + const { apply, overlays, root } = await openReaderHighlightAuthDialog(canvasElement); + const continueButton = await overlays.findByRole('button', { name: 'Continue' }); + const dialog = continueButton.closest('[role="dialog"]'); + if (!dialog) throw new Error('permission dialog not rendered'); + await expect(dialog.getRootNode()).toBe(root); + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(overlays.queryByRole('button', { name: 'Continue' })).toBeNull()); + // The reader-owned overlay container still serves the open verse-actions dialog. + await expect(overlays.getByRole('dialog', { name: 'Verse actions' })).toBeInTheDocument(); + await expect(root.activeElement).toBe(apply); + }, +}; + const rtlPassage = { id: 'JHN.1', content: @@ -214,13 +575,14 @@ export const RtlScriptureWithLtrChrome: Story = { ), play: async ({ canvasElement }) => { + const { root, reader } = await getReaderStory(canvasElement); await waitFor(async () => { - const renderer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const renderer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(renderer).toHaveAttribute('dir', 'rtl'); await expect(renderer?.textContent).toContain('في البدء'); }); - const renderer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]')!; + const renderer = root.querySelector('[data-slot="yv-bible-renderer"]')!; const poetry = renderer.querySelector('.q1')!; const table = renderer.querySelector('table'); const rendererStyle = getComputedStyle(renderer); @@ -229,24 +591,25 @@ export const RtlScriptureWithLtrChrome: Story = { await expect(Number.parseFloat(poetryStyle.paddingRight)).toBeGreaterThan(0); await expect(poetryStyle.paddingLeft).toBe('0px'); await expect(table).toBeInTheDocument(); - const heading = canvasElement.querySelector('h1')!; + const heading = root.querySelector('h1')!; await expect(heading).toHaveAttribute('dir', 'rtl'); await waitFor(async () => { await expect(heading.querySelectorAll('bdi[dir="auto"]')).toHaveLength(2); }); - await userEvent.click(screen.getByRole('button', { name: /footnote/i })); + await userEvent.click(reader.getByRole('button', { name: /footnote/i })); + const overlays = await getReaderOverlay(root); await waitFor(async () => { - const dialog = await screen.findByRole('dialog'); + const dialog = await overlays.findByRole('dialog'); await expect(dialog).toHaveAttribute('dir', 'ltr'); await expect(dialog.querySelector('[data-yv-sdk]')).toHaveAttribute('dir', 'rtl'); - await expect(await screen.findByText('حاشية عربية.')).toBeInTheDocument(); + await expect(await overlays.findByText('حاشية عربية.')).toBeInTheDocument(); }); - await userEvent.click(screen.getByRole('button', { name: /close/i })); + await userEvent.click(overlays.getByRole('button', { name: /close/i })); - await userEvent.click(screen.getByRole('button', { name: /settings/i })); + await userEvent.click(reader.getByRole('button', { name: /settings/i })); await waitFor(async () => { - const settings = await screen.findByText('Reader Settings'); + const settings = await overlays.findByText('Reader Settings'); await expect(settings.closest('[role="dialog"]')).toHaveAttribute('dir', 'ltr'); }); }, @@ -281,9 +644,10 @@ export const ArabicInterfaceAndScripture: Story = { ), play: async ({ canvasElement }) => { + const { root } = await getReaderStory(canvasElement); await waitFor(async () => { - await expect(canvasElement.querySelector('[data-yv-sdk][dir="rtl"]')).toBeInTheDocument(); - await expect(canvasElement.querySelector('[data-slot="yv-bible-renderer"]')).toHaveAttribute( + await expect(root.querySelector('[data-yv-sdk][dir="rtl"]')).toBeInTheDocument(); + await expect(root.querySelector('[data-slot="yv-bible-renderer"]')).toHaveAttribute( 'dir', 'rtl', ); @@ -323,9 +687,10 @@ export const ArabicInterfaceWithEnglishScripture: Story = { ), play: async ({ canvasElement }) => { + const { root } = await getReaderStory(canvasElement); const renderer = await waitFor(async () => { - await expect(canvasElement.querySelector('[data-yv-sdk][dir="rtl"]')).toBeInTheDocument(); - const element = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + await expect(root.querySelector('[data-yv-sdk][dir="rtl"]')).toBeInTheDocument(); + const element = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(element).toHaveAttribute('dir', 'ltr'); return element!; }); @@ -365,17 +730,22 @@ export const ForcedRtlChromeGeometry: Story = { ), play: async ({ canvasElement }) => { - const previous = await screen.findByRole('button', { name: 'Previous chapter' }); - const chapter = screen.getByRole('button', { name: 'Change Bible book and chapter' }); - const next = screen.getByRole('button', { name: 'Next chapter' }); + const { root, reader } = await getReaderStory(canvasElement); + const { + root: chapterRoot, + picker: chapterPicker, + trigger: chapter, + } = await getPickerQueries(canvasElement, 'Change Bible book and chapter'); + const previous = chapterPicker.getByRole('button', { name: 'Previous chapter' }); + const next = chapterPicker.getByRole('button', { name: 'Next chapter' }); const renderer = await waitFor(async () => { - const element = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const element = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(element).toBeInTheDocument(); return element; }); await expect(renderer).toHaveAttribute('dir', 'ltr'); - await expect(canvasElement.querySelector('h1')).toHaveAttribute('dir', 'ltr'); + await expect(root.querySelector('h1')).toHaveAttribute('dir', 'ltr'); await expect(previous.getBoundingClientRect().left).toBeGreaterThan( chapter.getBoundingClientRect().left, ); @@ -387,8 +757,8 @@ export const ForcedRtlChromeGeometry: Story = { await expect(chapter).toBeEnabled(); }); await userEvent.click(chapter); - const chapterDialog = await screen.findByRole('dialog'); - const chapterCanvas = within(chapterDialog); + const chapterCanvas = await getPickerOverlay(chapterRoot); + const chapterDialog = chapterCanvas.getByRole('dialog'); const intro = chapterCanvas.getByTestId('intro-chapter-button'); const chapterOne = chapterCanvas.getByRole('button', { name: '1' }); const chapterTwo = chapterCanvas.getByRole('button', { name: '2' }); @@ -409,9 +779,12 @@ export const ForcedRtlChromeGeometry: Story = { ); await userEvent.click(chapterCanvas.getByRole('button', { name: 'Close' })); - await userEvent.click(screen.getByRole('button', { name: 'Change Bible version' })); - const versionDialog = await screen.findByRole('dialog'); - const versionCanvas = within(versionDialog); + const { root: versionRoot, trigger: versionTrigger } = await getPickerQueries( + canvasElement, + 'Change Bible version', + ); + await userEvent.click(versionTrigger); + const versionCanvas = await getPickerOverlay(versionRoot); const firstVersion = versionCanvas.getAllByRole('listitem')[0]!; const tile = firstVersion.querySelector('[data-slot="item-media"]')!; const label = firstVersion.querySelector('[data-slot="item-content"]')!; @@ -424,8 +797,8 @@ export const ForcedRtlChromeGeometry: Story = { ).toHaveAttribute('dir', 'auto'); await userEvent.click(versionCanvas.getByRole('button', { name: 'Close' })); - await userEvent.click(screen.getByRole('button', { name: 'Settings' })); - const settingsDialog = await screen.findByRole('dialog'); + await userEvent.click(reader.getByRole('button', { name: 'Settings' })); + const settingsDialog = await (await getReaderOverlay(root)).findByRole('dialog'); const settingsCanvas = within(settingsDialog); const decrease = settingsCanvas.getByTestId('decrease-font-size'); const increase = settingsCanvas.getByTestId('increase-font-size'); @@ -534,9 +907,10 @@ export const CustomStyling: Story = { ), play: async ({ canvasElement }) => { + const { root } = await getReaderStory(canvasElement); await waitFor( async () => { - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(verseContainer).toBeInTheDocument(); }, { timeout: 5000 }, @@ -564,9 +938,10 @@ export const FontSizeOutOfRange: Story = { ), play: async ({ canvasElement }) => { + const { root } = await getReaderStory(canvasElement); await waitFor( async () => { - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(verseContainer).toBeInTheDocument(); }, { timeout: 5000 }, @@ -611,15 +986,16 @@ export const FootnotesPersistAfterFontSizeChange: Story = { ), play: async ({ canvasElement }) => { + const { root, reader } = await getReaderStory(canvasElement); await waitFor( async () => { - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(verseContainer).toBeInTheDocument(); }, { timeout: 5000 }, ); - const getFootnoteButtons = () => canvasElement.querySelectorAll('[data-verse-footnote] button'); + const getFootnoteButtons = () => root.querySelectorAll('[data-verse-footnote] button'); await waitFor( async () => { @@ -631,14 +1007,15 @@ export const FootnotesPersistAfterFontSizeChange: Story = { const initialFootnoteCount = getFootnoteButtons().length; - const settingsButton = screen.getByRole('button', { name: /settings/i }); + const settingsButton = reader.getByRole('button', { name: /settings/i }); await userEvent.click(settingsButton); + const settings = await getReaderOverlay(root); await waitFor(async () => { - await expect(await screen.findByText('Reader Settings')).toBeInTheDocument(); + await expect(await settings.findByText('Reader Settings')).toBeInTheDocument(); }); - const increaseFontButton = screen.getByTestId('increase-font-size'); + const increaseFontButton = settings.getByTestId('increase-font-size'); await userEvent.click(increaseFontButton); await waitFor(async () => { @@ -646,7 +1023,7 @@ export const FootnotesPersistAfterFontSizeChange: Story = { await expect(footnoteButtons.length).toBe(initialFootnoteCount); }); - const decreaseFontButton = screen.getByTestId('decrease-font-size'); + const decreaseFontButton = settings.getByTestId('decrease-font-size'); await userEvent.click(decreaseFontButton); await userEvent.click(decreaseFontButton); @@ -677,32 +1054,34 @@ export const ThemeOverridesProvider: Story = { ), play: async ({ canvasElement }) => { + const { root } = await getReaderStory(canvasElement); await waitFor( async () => { - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(verseContainer).toBeInTheDocument(); }, { timeout: 5000 }, ); - const readerTheme = canvasElement.querySelector('[data-yv-theme="light"]'); + const readerTheme = root.querySelector('[data-yv-theme="light"]'); await expect(readerTheme).toBeInTheDocument(); await waitFor( async () => { - const footnoteButton = canvasElement.querySelector('[data-verse-footnote] button'); + const footnoteButton = root.querySelector('[data-verse-footnote] button'); await expect(footnoteButton).toBeInTheDocument(); }, { timeout: 5000 }, ); - const footnoteButton = canvasElement.querySelector('[data-verse-footnote] button'); + const footnoteButton = root.querySelector('[data-verse-footnote] button'); await expect(footnoteButton?.closest('[data-yv-theme="light"]')).toBeInTheDocument(); await userEvent.click(footnoteButton!); + const overlays = await getReaderOverlay(root); await waitFor(async () => { - const popover = document.querySelector('[data-slot="popover-content"]'); + const popover = overlays.queryByRole('dialog'); await expect(popover).toBeInTheDocument(); await expect(popover?.closest('[data-yv-theme="light"]')).toBeInTheDocument(); }); @@ -724,26 +1103,28 @@ export const SignInFlow: Story = { ), - play: async () => { + play: async ({ canvasElement }) => { + const { root, reader } = await getReaderStory(canvasElement); await waitFor( async () => { - const userMenuTrigger = screen.getByTestId('user-menu-trigger'); + const userMenuTrigger = reader.getByTestId('user-menu-trigger'); await expect(userMenuTrigger).toBeInTheDocument(); }, { timeout: 5000 }, ); - const userMenuTrigger = screen.getByTestId('user-menu-trigger'); + const userMenuTrigger = reader.getByTestId('user-menu-trigger'); await expect(userMenuTrigger.querySelector('img')).not.toBeInTheDocument(); await userEvent.click(userMenuTrigger); + const overlays = await getReaderOverlay(root); await waitFor(async () => { - const signInButton = await screen.findByRole('button', { name: /sign in/i }); + const signInButton = await overlays.findByRole('button', { name: /sign in/i }); await expect(signInButton).toBeInTheDocument(); }); - const signInButton = screen.getByRole('button', { name: /sign in/i }); + const signInButton = overlays.getByRole('button', { name: /sign in/i }); await userEvent.click(signInButton); await expect(signInMock).toHaveBeenCalled(); @@ -771,16 +1152,17 @@ export const SignOutFlow: Story = { ), - play: async () => { + play: async ({ canvasElement }) => { + const { root, reader } = await getReaderStory(canvasElement); await waitFor( async () => { - const userMenuTrigger = screen.getByTestId('user-menu-trigger'); + const userMenuTrigger = reader.getByTestId('user-menu-trigger'); await expect(userMenuTrigger).toBeInTheDocument(); }, { timeout: 5000 }, ); - const userMenuTrigger = screen.getByTestId('user-menu-trigger'); + const userMenuTrigger = reader.getByTestId('user-menu-trigger'); await waitFor(async () => { const avatar = userMenuTrigger.querySelector('[data-slot="avatar"]'); @@ -789,16 +1171,17 @@ export const SignOutFlow: Story = { await userEvent.click(userMenuTrigger); + const overlays = await getReaderOverlay(root); await waitFor(async () => { - const signOutButton = await screen.findByRole('button', { name: /sign out/i }); + const signOutButton = await overlays.findByRole('button', { name: /sign out/i }); await expect(signOutButton).toBeInTheDocument(); }); - const signOutButton = screen.getByRole('button', { name: /sign out/i }); + const signOutButton = overlays.getByRole('button', { name: /sign out/i }); await userEvent.click(signOutButton); await waitFor(async () => { - const userMenuTriggerAfterSignOut = screen.getByTestId('user-menu-trigger'); + const userMenuTriggerAfterSignOut = reader.getByTestId('user-menu-trigger'); await expect(userMenuTriggerAfterSignOut.querySelector('img')).not.toBeInTheDocument(); }); }, @@ -825,16 +1208,17 @@ export const AuthenticatedWithAvatar: Story = { ), - play: async () => { + play: async ({ canvasElement }) => { + const { reader } = await getReaderStory(canvasElement); await waitFor( async () => { - const userMenuTrigger = screen.getByTestId('user-menu-trigger'); + const userMenuTrigger = reader.getByTestId('user-menu-trigger'); await expect(userMenuTrigger).toBeInTheDocument(); }, { timeout: 5000 }, ); - const userMenuTrigger = screen.getByTestId('user-menu-trigger'); + const userMenuTrigger = reader.getByTestId('user-menu-trigger'); // Radix only renders the once it loads successfully. await waitFor(async () => { @@ -869,18 +1253,17 @@ export const LoadsSavedPreferencesFromLocalStorage: Story = { ), play: async ({ canvasElement }) => { + const { root, reader } = await getReaderStory(canvasElement); await waitFor( async () => { - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(verseContainer).toBeInTheDocument(); }, { timeout: 5000 }, ); // Verify the saved settings were applied via CSS variables - const verseContainer = canvasElement.querySelector( - '[data-slot="yv-bible-renderer"]', - )!; + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]')!; await expect(verseContainer.style.getPropertyValue('--yv-reader-font-size')).toBe('18px'); // The legacy Source Serif value seeded above is migrated forward on hydrate. await expect(verseContainer.style.getPropertyValue('--yv-reader-font-family')).toBe( @@ -891,17 +1274,18 @@ export const LoadsSavedPreferencesFromLocalStorage: Story = { ); // Open settings and verify the correct font family button is active - const settingsButton = screen.getByRole('button', { name: /settings/i }); + const settingsButton = reader.getByRole('button', { name: /settings/i }); await userEvent.click(settingsButton); + const settings = await getReaderOverlay(root); await waitFor(async () => { - await expect(await screen.findByText('Reader Settings')).toBeInTheDocument(); + await expect(await settings.findByText('Reader Settings')).toBeInTheDocument(); }); - const untitledSerifButton = screen.getByRole('button', { name: /untitled/i }); + const untitledSerifButton = settings.getByRole('button', { name: /untitled/i }); await expect(untitledSerifButton).toHaveClass('yv:bg-primary'); - const interButton = screen.getByRole('button', { name: /inter/i }); + const interButton = settings.getByRole('button', { name: /inter/i }); await expect(interButton).not.toHaveClass('yv:bg-primary'); }, }; @@ -925,16 +1309,17 @@ export const AuthenticatedWithoutAvatar: Story = { ), - play: async () => { + play: async ({ canvasElement }) => { + const { root, reader } = await getReaderStory(canvasElement); await waitFor( async () => { - const userMenuTrigger = screen.getByTestId('user-menu-trigger'); + const userMenuTrigger = reader.getByTestId('user-menu-trigger'); await expect(userMenuTrigger).toBeInTheDocument(); }, { timeout: 5000 }, ); - const userMenuTrigger = screen.getByTestId('user-menu-trigger'); + const userMenuTrigger = reader.getByTestId('user-menu-trigger'); // No image URL → initials fallback ("Test User" → "TU") inside the avatar circle. await expect(userMenuTrigger.querySelector('img')).not.toBeInTheDocument(); await waitFor(async () => { @@ -945,8 +1330,9 @@ export const AuthenticatedWithoutAvatar: Story = { await userEvent.click(userMenuTrigger); + const overlays = await getReaderOverlay(root); await waitFor(async () => { - const signOutButton = await screen.findByRole('button', { name: /sign out/i }); + const signOutButton = await overlays.findByRole('button', { name: /sign out/i }); await expect(signOutButton).toBeInTheDocument(); }); }, @@ -975,32 +1361,36 @@ export const WithoutAuth: Story = { ), play: async ({ canvasElement }) => { + const { root, reader } = await getReaderStory(canvasElement); // Wait for the Bible content to load await waitFor( async () => { - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(verseContainer).toBeInTheDocument(); }, { timeout: 5000 }, ); // Verify Bible content is displayed - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(verseContainer).toBeInTheDocument(); // User menu should not be visible when auth is disabled - const userMenuTrigger = canvasElement.querySelector('[data-testid="user-menu-trigger"]'); + const userMenuTrigger = root.querySelector('[data-testid="user-menu-trigger"]'); await expect(userMenuTrigger).not.toBeInTheDocument(); // Chapter picker and version picker should still work - const chapterButton = screen.getByRole('button', { name: /change bible book and chapter/i }); + const { trigger: chapterButton } = await getPickerQueries( + canvasElement, + /change bible book and chapter/i, + ); await expect(chapterButton).toBeInTheDocument(); - const versionButton = await screen.findByRole('button', { name: /bible version/i }); + const { trigger: versionButton } = await getPickerQueries(canvasElement, /bible version/i); await expect(versionButton).toBeInTheDocument(); // Settings should still work - const settingsButton = screen.getByRole('button', { name: /settings/i }); + const settingsButton = reader.getByRole('button', { name: /settings/i }); await expect(settingsButton).toBeInTheDocument(); }, }; @@ -1041,9 +1431,9 @@ export const VersionButtonLoadingStates: Story = { ), - play: async () => { + play: async ({ canvasElement }) => { // Wait for the toolbar to mount, then capture the button in loading state - const versionButton = await screen.findByRole('button', { name: /bible version/i }); + const { trigger: versionButton } = await getPickerQueries(canvasElement, /bible version/i); // The delayed MSW handler guarantees the loading state is visible const spinner = versionButton.querySelector('[role="status"]'); @@ -1089,17 +1479,18 @@ export const JoshuaIntroChapter: Story = { ), play: async ({ canvasElement }) => { + const { root } = await getReaderStory(canvasElement); // Wait for the intro content to fully load (not just the renderer element) await waitFor( async () => { - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(verseContainer).toBeInTheDocument(); await expect(verseContainer?.textContent).toContain('Joshua'); }, { timeout: 5000 }, ); - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]')!; + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]')!; // The unavailable message should not appear const hasUnavailableText = verseContainer.textContent?.includes('not available'); @@ -1129,11 +1520,12 @@ export const JoshuaIntroChapter: Story = { await expect(verseContainer.textContent).toContain('Yahweh'); // Toolbar trigger should show "Intro" (title-case label), NOT "INTRO" (raw chapter ID) + const { trigger: chapterButton } = await getPickerQueries( + canvasElement, + /change bible book and chapter/i, + ); await waitFor( async () => { - const chapterButton = screen.getByRole('button', { - name: /change bible book and chapter/i, - }); await expect(chapterButton.textContent).toContain('Intro'); await expect(chapterButton.textContent).not.toContain('INTRO'); }, @@ -1147,8 +1539,9 @@ export const JoshuaIntroChapter: Story = { // Click the footnote and verify the popover shows note content without verse reference await userEvent.click(footnoteButton!); + const overlays = await getReaderOverlay(root); await waitFor(async () => { - const popover = document.querySelector('[role="dialog"]'); + const popover = overlays.queryByRole('dialog'); await expect(popover).toBeInTheDocument(); // Should show note content (e.g., "See Rashi") @@ -1308,27 +1701,32 @@ export const ChapterChangeLoadingOverlay: Story = { ), play: async ({ canvasElement }) => { + const { root } = await getReaderStory(canvasElement); await waitFor( async () => { - const renderer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const renderer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(renderer?.textContent).toContain('JHN.1'); }, { timeout: 5000 }, ); - const nextButton = screen.getByRole('button', { name: /next chapter/i }); + const { picker: chapterPicker } = await getPickerQueries( + canvasElement, + /change bible book and chapter/i, + ); + const nextButton = chapterPicker.getByRole('button', { name: /next chapter/i }); await userEvent.click(nextButton); - const rendererAfterClick = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const rendererAfterClick = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(rendererAfterClick?.textContent).toContain('JHN.1'); await waitFor( async () => { - const overlay = canvasElement.querySelector('[aria-label="Loading passage"]'); + const overlay = root.querySelector('[aria-label="Loading passage"]'); await expect(overlay).toBeInTheDocument(); await expect(overlay).toHaveAttribute('role', 'status'); - await expect(canvasElement.querySelector('[class*="opacity-40"]')).toBeInTheDocument(); - const renderer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + await expect(root.querySelector('[class*="opacity-40"]')).toBeInTheDocument(); + const renderer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(renderer?.textContent).toContain('JHN.1'); }, { timeout: 2000 }, @@ -1336,17 +1734,15 @@ export const ChapterChangeLoadingOverlay: Story = { await waitFor( async () => { - const renderer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const renderer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(renderer?.textContent).toContain('JHN.2'); - await expect( - canvasElement.querySelector('[aria-label="Loading passage"]'), - ).not.toBeInTheDocument(); - await expect(canvasElement.querySelector('[class*="opacity-40"]')).not.toBeInTheDocument(); + await expect(root.querySelector('[aria-label="Loading passage"]')).not.toBeInTheDocument(); + await expect(root.querySelector('[class*="opacity-40"]')).not.toBeInTheDocument(); }, { timeout: 5000 }, ); - const scroller = canvasElement.querySelector('main'); + const scroller = root.querySelector('main'); await expect(scroller?.scrollTop).toBe(0); }, }; diff --git a/packages/ui/src/components/bible-reader.test.tsx b/packages/ui/src/components/bible-reader.test.tsx index 357947b2..385576bf 100644 --- a/packages/ui/src/components/bible-reader.test.tsx +++ b/packages/ui/src/components/bible-reader.test.tsx @@ -3,13 +3,16 @@ */ // We stub ResizeObserver for jsdom (used by Radix/@floating-ui). The stub methods are intentionally no-ops. import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useState, type ReactElement } from 'react'; +import { hydrateRoot, type Root } from 'react-dom/client'; +import { renderToString } from 'react-dom/server'; import type { BibleBook, BibleVersion } from '@youversion/platform-core'; -import type { HookOverrides } from '@youversion/platform-react-hooks'; +import { YouVersionContext, type HookOverrides } from '@youversion/platform-react-hooks'; import { HookOverrideProvider } from '@/test/hook-overrides'; import { InterfaceDirectionProvider } from '@/lib/direction'; +import { ReuseShadowBoundary } from '@/lib/shadow-isolation'; import { BIBLE_READER_SPACING, BibleReader, @@ -79,7 +82,11 @@ function defaultOverrides(): HookOverrides { } function renderWithOverrides(ui: ReactElement) { - return render({ui}); + return render( + + {ui} + , + ); } function overridesRecordingVersionLanguage() { @@ -203,21 +210,155 @@ describe('createBibleThemeSettingsContentHandlers', () => { }); }); -it('injects component styles when theme settings render standalone', () => { - render( - , - ); +describe('BibleThemeSettingsContent public boundary', () => { + it('owns one empty SSR host and reuses it during hydration', async () => { + const element = ( + + ); + const serverMarkup = renderToString(element); + + expect(serverMarkup).toBe('
'); + + const container = document.createElement('div'); + container.innerHTML = serverMarkup; + document.body.append(container); + const serverHost = container.firstElementChild; + const recoverableErrors: unknown[] = []; + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + let root: Root | undefined; + + try { + await act(async () => { + root = hydrateRoot(container, element, { + onRecoverableError: (error) => recoverableErrors.push(error), + }); + }); + + const host = await waitFor(() => { + const candidate = container.querySelector('[data-yv-shadow-host]'); + if (!candidate?.shadowRoot?.querySelector('[data-testid="line-spacing"]')) { + throw new Error('settings controls not mounted inside an open shadow root'); + } + return candidate; + }); + + expect(container.querySelectorAll('[data-yv-shadow-host]')).toHaveLength(1); + expect(host).toBe(serverHost); + expect(host.childNodes).toHaveLength(0); + expect(host.shadowRoot?.querySelectorAll('[data-yv-shadow-host]')).toHaveLength(0); + expect(recoverableErrors).toEqual([]); + expect(consoleError).not.toHaveBeenCalled(); + } finally { + if (root) await act(async () => root?.unmount()); + consoleError.mockRestore(); + container.remove(); + } + }); + + it('preserves standalone callbacks, limits, selected font, theme, direction, and controls', async () => { + const user = userEvent.setup(); + const onFontIncreased = vi.fn(); + const onFontDecreased = vi.fn(); + const onFontSelected = vi.fn<(fontFamily: FontFamily) => void>(); + const onChangeLineSpacing = vi.fn(); + + function StandaloneSettings({ + direction, + fontSize = 12, + fontFamily = UNTITLED_SERIF_FONT, + theme = 'light', + providerTheme = 'dark', + }: { + direction: 'ltr' | 'rtl'; + fontSize?: number; + fontFamily?: FontFamily; + theme?: 'light' | 'dark'; + providerTheme?: 'light' | 'dark'; + }) { + return ( + + + + + + ); + } - expect(document.head.querySelector('style[data-href="yv-sdk-components"]')).not.toBeNull(); + const view = render(); + const host = await waitFor(() => { + const candidate = view.container.querySelector('[data-yv-shadow-host]'); + if (!candidate?.shadowRoot?.querySelector('[data-testid="line-spacing"]')) { + throw new Error('standalone settings not mounted'); + } + return candidate; + }); + const root = host.shadowRoot!; + const shadowScope = root.querySelector('[data-yv-shadow-content-wrapper]')!; + const settings = within(shadowScope); + const body = settings.getByTestId('line-spacing').closest('[data-yv-sdk]'); + const wrapper = root.querySelector('[data-yv-shadow-content-wrapper]'); + const decrease = settings.getByRole('button', { name: 'Decrease font size' }); + const increase = settings.getByRole('button', { name: 'Increase font size' }); + const lineSpacing = settings.getByRole('button', { name: 'Change line spacing' }); + const inter = settings.getByRole('button', { name: /Inter/ }); + const serif = settings.getByRole('button', { name: /Untitled Serif/ }); + + expect(settings.getAllByRole('button')).toHaveLength(5); + expect(wrapper).toHaveAttribute('data-yv-theme', 'light'); + expect(body).toHaveAttribute('data-yv-theme', 'light'); + expect(body).toHaveAttribute('dir', 'rtl'); + expect(decrease).toBeDisabled(); + expect(serif.className).toContain('yv:bg-primary'); + expect(inter.className).not.toContain('yv:bg-primary'); + + await user.click(decrease); + expect(onFontDecreased).not.toHaveBeenCalled(); + await user.click(increase); + expect(onFontIncreased).toHaveBeenCalledTimes(1); + await user.click(inter); + expect(onFontSelected).toHaveBeenCalledWith(INTER_FONT); + await user.click(lineSpacing); + expect(onChangeLineSpacing).toHaveBeenCalledTimes(1); + + view.rerender( + , + ); + expect(body).toHaveAttribute('dir', 'ltr'); + expect(wrapper).toHaveAttribute('data-yv-theme', 'dark'); + expect(body).toHaveAttribute('data-yv-theme', 'dark'); + expect(inter.className).toContain('yv:bg-primary'); + expect(serif.className).not.toContain('yv:bg-primary'); + expect(increase).toBeDisabled(); + const increaseCallCountAtMaximum = onFontIncreased.mock.calls.length; + await user.click(increase); + expect(onFontIncreased).toHaveBeenCalledTimes(increaseCallCountAtMaximum); + await user.click(decrease); + expect(onFontDecreased).toHaveBeenCalledTimes(1); + }); }); describe('BibleReader theme settings', () => { diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index c074b518..b707c9e4 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -30,6 +30,7 @@ import { } from '@youversion/platform-react-hooks'; import React, { createContext, + useCallback, useContext, useEffect, useLayoutEffect, @@ -49,6 +50,7 @@ import { InfoIcon } from './icons/info'; import { LoaderIcon } from './icons/loader'; import { PersonIcon } from './icons/person'; import { ProfileAvatar } from './profile-avatar'; +import { ReuseShadowBoundary, ShadowIsolationBoundary } from '@/lib/shadow-isolation'; import { Button } from './ui/button'; import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from './ui/popover'; import { VerseActionPopover } from './verse-action-popover'; @@ -63,6 +65,7 @@ import { YouVersionPlatformConfiguration, getPassageAttribution } from '@youvers import { BibleReaderSearch, type BibleReaderSearchPressData } from './bible-reader-search'; import { useTransientVerseFocus, type VerseFocusRequest } from '@/lib/use-transient-verse-focus'; import { BibleReaderNavigation } from './bible-reader-navigation'; +import { VerseActionPopoverFocusRestoreProvider } from '@/lib/verse-action-popover-focus'; type BibleReaderContextType = { book: string; @@ -761,17 +764,19 @@ function Root({ }; return ( - - -
- {children} -
-
+ + + +
+ {children} +
+
+
); } @@ -875,6 +880,12 @@ function Content() { const [selectedVerses, setSelectedVerses] = useState([]); const [popoverOpen, setPopoverOpen] = useState(false); const [anchorElement, setAnchorElement] = useState(null); + const verseFocusRestoreTargetRef = useRef(null); + const takeVerseFocusRestoreTarget = useCallback(() => { + const target = verseFocusRestoreTargetRef.current; + verseFocusRestoreTargetRef.current = null; + return target; + }, []); const lastSelectionRef = useRef([]); const { @@ -1168,6 +1179,14 @@ function Content() {
{ + let activeElement = event.currentTarget.ownerDocument.activeElement; + while (activeElement?.shadowRoot?.activeElement) { + activeElement = activeElement.shadowRoot.activeElement; + } + verseFocusRestoreTargetRef.current = + activeElement === event.currentTarget.ownerDocument.body ? null : activeElement; + }} >

- + + + {/* `verseActions="none"`: the host renders its own action UI (e.g. a @@ -1228,21 +1249,23 @@ function Content() { all — two action surfaces would stack. Selection, painting, and every payload above are untouched. */} {verseActions !== 'none' && ( - 0} - onOpenChange={handlePopoverOpenChange} - activeHighlights={activeHighlights} - selectedVerses={selectedVerses} - highlightedVerses={highlightedVerses} - highlightsEnabled={highlightsEnabled} - anchorElement={anchorElement} - scrollRoot={scrollContainerRef.current} - onHighlight={handleHighlight} - onClearHighlight={handleClearHighlight} - onCopy={handleCopy} - onShare={handleShare} - theme={background} - /> + + 0} + onOpenChange={handlePopoverOpenChange} + activeHighlights={activeHighlights} + selectedVerses={selectedVerses} + highlightedVerses={highlightedVerses} + highlightsEnabled={highlightsEnabled} + anchorElement={anchorElement} + scrollRoot={scrollContainerRef.current} + onHighlight={handleHighlight} + onClearHighlight={handleClearHighlight} + onCopy={handleCopy} + onShare={handleShare} + theme={background} + /> + )} {auth.isAuthenticated ? ( ) : ( - + + + + {({ chapterLabel, currentBook, loading }) => ( +
+ + + + + +
+ )} +
+
+
+ + + + + {({ version, loading }) => ( - - - - )} - - - - - - {({ version, loading }) => ( - - )} - - - + )} + + + + {showSearch ? : null} @@ -1761,16 +1798,18 @@ function Toolbar({ - + + + )} diff --git a/packages/ui/src/components/bible-theme-settings.stories.tsx b/packages/ui/src/components/bible-theme-settings.stories.tsx new file mode 100644 index 00000000..4b9b07a4 --- /dev/null +++ b/packages/ui/src/components/bible-theme-settings.stories.tsx @@ -0,0 +1,244 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { http, HttpResponse } from 'msw'; +import { useState } from 'react'; +import { createPortal } from 'react-dom'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; +import i18n from '../i18n'; +import { INTER_FONT, UNTITLED_SERIF_FONT, type FontFamily } from '../lib/verse-html-utils'; +import { BibleThemeSettingsContent } from './bible-reader'; +import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; +import { YvComponentStyles } from '../lib/yv-styles-components'; + +function ConstrainedSettings(): React.ReactNode { + const [boundary, setBoundary] = useState(null); + const [fontFamily, setFontFamily] = useState(INTER_FONT); + const [fontSize, setFontSize] = useState(16); + return ( + <> + +
+
+ + Settings + + undefined} + onFontDecreased={() => setFontSize((current) => current - 1)} + onChangeLineSpacing={() => undefined} + /> + + +
+
+ + ); +} + +function MixedLightAndShadowControls(): React.ReactNode { + const [portalContainer, setPortalContainer] = useState(null); + return ( + <> +
+ + Settings + Header action} + onFocusOutside={(event) => event.preventDefault()} + > + undefined} + onFontIncreased={() => undefined} + onFontDecreased={() => undefined} + onChangeLineSpacing={() => undefined} + /> + {portalContainer + ? createPortal( + <> + + + , + portalContainer, + ) + : null} + + + + ); +} + +const meta = { + title: 'Components/BibleReader/Settings', + component: ConstrainedSettings, + tags: ['integration', 'shadow-dom', 'cross-browser'], + parameters: { + layout: 'fullscreen', + msw: { + handlers: [ + http.get('*/v1/fonts/1/stylesheet', () => + HttpResponse.text('', { headers: { 'Content-Type': 'text/css' } }), + ), + ], + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const FontControlsRemainReachableInConstrainedSpace: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByTestId('settings-trigger')); + const ownerDocument = canvasElement.ownerDocument; + const dialog = await within(ownerDocument.body).findByRole('dialog'); + const settingsHost = await waitFor(() => { + const candidate = dialog.querySelector('[data-yv-shadow-host]'); + if (!candidate?.shadowRoot) throw new Error('settings shadow root not attached'); + return candidate; + }); + const settingsRoot = settingsHost.shadowRoot!; + const settingsScope = settingsRoot.querySelector( + '[data-yv-shadow-content-wrapper]', + )!; + const settings = within(settingsScope); + const controls = settings.getAllByRole('button'); + void expect(controls).toHaveLength(5); + const decreaseFontSizeButton = settings.getByTestId('decrease-font-size'); + void expect(controls[0]).toBe(decreaseFontSizeButton); + const serifButton = settings.getByRole('button', { + name: `${i18n.t('fontLabel')} ${i18n.t('untitledSerifFontName')}`, + }); + void expect(controls[controls.length - 1]).toBe(serifButton); + const closeButton = within(dialog).getByRole('button', { name: i18n.t('closeAriaLabel') }); + void expect(ownerDocument.activeElement).toBe(closeButton); + await userEvent.tab(); + void expect(settingsRoot.activeElement).toBe(decreaseFontSizeButton); + await userEvent.keyboard('{Enter}'); + void expect(canvas.getByTestId('settings-boundary')).toHaveAttribute('data-font-size', '15'); + + let focusedControlReceivedTab = false; + decreaseFontSizeButton.addEventListener( + 'keydown', + (event) => { + if (event.key === 'Tab') focusedControlReceivedTab = true; + }, + { once: true }, + ); + for (const control of controls.slice(1)) { + await userEvent.tab(); + void expect(settingsRoot.activeElement).toBe(control); + } + void expect(focusedControlReceivedTab).toBe(true); + await userEvent.keyboard('{Enter}'); + void expect(canvas.getByTestId('settings-boundary')).toHaveAttribute( + 'data-font-family', + UNTITLED_SERIF_FONT, + ); + await userEvent.tab(); + void expect(ownerDocument.activeElement).toBe(closeButton); + for (const control of [...controls].reverse()) { + await userEvent.tab({ shift: true }); + void expect(settingsRoot.activeElement).toBe(control); + } + await userEvent.tab({ shift: true }); + void expect(ownerDocument.activeElement).toBe(closeButton); + // Use the real settings body and Radix collision cap. Unlike a click helper, + // hit testing can detect controls rendered but clipped below the panel. + const settingsBody = serifButton.closest('[data-yv-sdk]'); + if (!settingsBody) throw new Error('settings body not rendered'); + const boundary = canvas.getByTestId('settings-boundary'); + const header = within(dialog).getByRole('heading'); + await Promise.all(dialog.getAnimations().map((animation) => animation.finished)); + await waitFor(() => { + const panelRect = dialog.getBoundingClientRect(); + const boundaryRect = boundary.getBoundingClientRect(); + const availableHeight = Number.parseFloat( + getComputedStyle(dialog).getPropertyValue('--radix-popover-content-available-height'), + ); + void expect(availableHeight).toBeGreaterThan(0); + void expect(Math.abs(panelRect.height - availableHeight)).toBeLessThanOrEqual(1); + void expect( + header.getBoundingClientRect().height + settingsBody.scrollHeight, + ).toBeGreaterThan(availableHeight); + void expect(panelRect.top).toBeGreaterThanOrEqual(boundaryRect.top); + void expect(panelRect.bottom).toBeLessThanOrEqual(boundaryRect.bottom); + void expect(panelRect.left).toBeGreaterThanOrEqual(boundaryRect.left); + void expect(panelRect.right).toBeLessThanOrEqual(boundaryRect.right); + void expect(settingsBody.scrollHeight).toBeGreaterThan(settingsBody.clientHeight); + void expect(getComputedStyle(settingsBody).overflowY).toBe('auto'); + }); + const headerOffset = header.getBoundingClientRect().top - dialog.getBoundingClientRect().top; + for (const control of controls) { + control.scrollIntoView({ block: 'center' }); + await waitFor(() => { + const controlRect = control.getBoundingClientRect(); + const bodyRect = settingsBody.getBoundingClientRect(); + void expect(controlRect.top).toBeGreaterThanOrEqual(bodyRect.top); + void expect(controlRect.bottom).toBeLessThanOrEqual(bodyRect.bottom); + const hit = settingsRoot.elementFromPoint( + controlRect.left + controlRect.width / 2, + controlRect.top + controlRect.height / 2, + ); + void expect(control.contains(hit)).toBe(true); + const currentHeaderOffset = + header.getBoundingClientRect().top - dialog.getBoundingClientRect().top; + void expect(Math.abs(currentHeaderOffset - headerOffset)).toBeLessThanOrEqual(1); + }); + } + void expect(settingsBody.scrollTop).toBeGreaterThan(0); + await userEvent.keyboard('{Escape}'); + await waitFor(() => void expect(dialog).not.toBeInTheDocument()); + void expect(ownerDocument.activeElement).toBe(canvas.getByTestId('settings-trigger')); + }, +}; + +export const MixedAndPortaledControlsPreserveTabOrder: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByTestId('mixed-controls-trigger')); + const ownerDocument = canvasElement.ownerDocument; + const dialog = await within(ownerDocument.body).findByRole('dialog'); + const headerAction = within(dialog).getByTestId('header-action'); + const closeButton = within(dialog).getByRole('button', { name: i18n.t('closeAriaLabel') }); + void expect(ownerDocument.activeElement).toBe(headerAction); + await userEvent.tab(); + void expect(ownerDocument.activeElement).toBe(closeButton); + + const settingsHost = await waitFor(() => { + const candidate = dialog.querySelector('[data-yv-shadow-host]'); + if (!candidate?.shadowRoot) throw new Error('settings shadow root not attached'); + return candidate; + }); + const settingsScope = settingsHost.shadowRoot!.querySelector( + '[data-yv-shadow-content-wrapper]', + )!; + const firstSetting = within(settingsScope).getByTestId('decrease-font-size'); + await userEvent.tab(); + void expect(settingsHost.shadowRoot!.activeElement).toBe(firstSetting); + + const firstPortalAction = canvas.getByTestId('portal-first'); + const secondPortalAction = canvas.getByTestId('portal-second'); + firstPortalAction.focus(); + void expect(ownerDocument.activeElement).toBe(firstPortalAction); + await userEvent.tab(); + void expect(ownerDocument.activeElement).toBe(secondPortalAction); + void expect(dialog).toBeInTheDocument(); + }, +}; diff --git a/packages/ui/src/components/bible-version-picker.shadow-isolation.stories.tsx b/packages/ui/src/components/bible-version-picker.shadow-isolation.stories.tsx new file mode 100644 index 00000000..d63b557b --- /dev/null +++ b/packages/ui/src/components/bible-version-picker.shadow-isolation.stories.tsx @@ -0,0 +1,810 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import type { BibleVersion, Language, Organization } from '@youversion/platform-core'; +import { YouVersionContext, type HookOverrides } from '@youversion/platform-react-hooks'; +import { useContext, useEffect, useState, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import { expect, userEvent, within } from 'storybook/test'; +import { ReuseShadowBoundary } from '../lib/shadow-isolation'; +import { ShadowRootHost } from '../lib/shadow-root-host'; +import mockBibles from '../test/mock-data/bibles.json'; +import mockLanguages from '../test/mock-data/languages.json'; +import mockOrganizations from '../test/mock-data/organizations.json'; +import { waitFor, waitForElement, waitForShadowRoot } from '../test/storybook-dom'; +import { BibleVersionPicker } from './bible-version-picker'; + +const VERSIONS = mockBibles.collections.default.data satisfies BibleVersion[]; +const LANGUAGES = mockLanguages.data.map(({ id, display_names }) => ({ + id, + language: display_names.en ?? id, + display_names: { en: display_names.en ?? id }, +})) satisfies Language[]; +const ORGANIZATIONS = Object.values(mockOrganizations) satisfies Organization[]; + +const HOOK_OVERRIDES = { + useVersion: (versionId) => ({ + version: VERSIONS.find((version) => version.id === versionId) ?? null, + loading: false, + error: null, + refetch: () => undefined, + }), + useVersions: () => ({ + versions: { data: VERSIONS, next_page_token: null }, + loading: false, + error: null, + refetch: () => undefined, + }), + useLanguages: () => ({ + languages: { data: LANGUAGES, next_page_token: null }, + loading: false, + error: null, + refetch: () => undefined, + }), + useLanguage: (languageId) => ({ + language: LANGUAGES.find((language) => language.id === languageId) ?? null, + loading: false, + error: null, + refetch: () => undefined, + }), + useOrganizations: (organizationIds) => ({ + organizations: new Map( + ORGANIZATIONS.filter((organization) => organizationIds.includes(organization.id)).map( + (organization) => [organization.id, organization], + ), + ), + }), +} satisfies HookOverrides; + +function FixtureProviders({ children }: { children: ReactNode }): ReactNode { + const parentContext = useContext(YouVersionContext); + const value = parentContext + ? { ...parentContext, hookOverrides: HOOK_OVERRIDES } + : { appKey: 'test', hookOverrides: HOOK_OVERRIDES }; + + return {children}; +} + +function AutomaticBibleVersionPicker({ + side = 'top', +}: { + side?: 'top' | 'right' | 'bottom' | 'left'; +}): React.ReactNode { + const [versionId, setVersionId] = useState(111); + + return ( + + + + + ); +} + +function CustomTriggerBibleVersionPicker(): React.ReactNode { + const [versionId, setVersionId] = useState(111); + + return ( + + + + + + + ); +} + +function TwoPickersInOneIsland(): React.ReactNode { + const [firstVersionId, setFirstVersionId] = useState(111); + const [secondVersionId, setSecondVersionId] = useState(111); + + return ( + + +
+ + + + +
+
+ + + + +
+
+
+ ); +} + +function SameOriginIframeHarness(): React.ReactNode { + const [iframeDocument, setIframeDocument] = useState(null); + const [container, setContainer] = useState(null); + + useEffect(() => { + if (!iframeDocument) return; + + const portalContainer = iframeDocument.createElement('div'); + portalContainer.setAttribute('data-testid', 'iframe-portal-container'); + portalContainer.style.setProperty('display', 'grid'); + portalContainer.style.setProperty('min-block-size', '100vh'); + portalContainer.style.setProperty('place-items', 'center'); + iframeDocument.body.append(portalContainer); + setContainer(portalContainer); + + return () => portalContainer.remove(); + }, [iframeDocument]); + + return ( + <> +