diff --git a/.changeset/many-lamps-watch.md b/.changeset/many-lamps-watch.md
new file mode 100644
index 00000000..0d518a07
--- /dev/null
+++ b/.changeset/many-lamps-watch.md
@@ -0,0 +1,7 @@
+---
+'@youversion/platform-react-ui': minor
+'@youversion/platform-core': minor
+'@youversion/platform-react-hooks': minor
+---
+
+Added loading skeleton to bible card
diff --git a/packages/ui/src/components/bible-card-skeleton.tsx b/packages/ui/src/components/bible-card-skeleton.tsx
new file mode 100644
index 00000000..105f76d3
--- /dev/null
+++ b/packages/ui/src/components/bible-card-skeleton.tsx
@@ -0,0 +1,45 @@
+import { BibleAppLogoLockup } from './bible-app-logo-lockup';
+import { Skeleton } from './ui/skeleton';
+
+type BibleCardSkeletonProps = {
+ showVersionPicker?: boolean;
+ background?: 'light' | 'dark';
+};
+
+export function BibleCardSkeleton({
+ showVersionPicker = false,
+ background = 'light',
+}: BibleCardSkeletonProps): React.ReactElement {
+ return (
+
+ Loading Bible verse
+
+
+
+ {showVersionPicker ? : null}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/components/bible-card.stories.tsx b/packages/ui/src/components/bible-card.stories.tsx
index 5bcdecea..6a53af20 100644
--- a/packages/ui/src/components/bible-card.stories.tsx
+++ b/packages/ui/src/components/bible-card.stories.tsx
@@ -1,6 +1,39 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { within, expect, userEvent, screen, waitFor } from 'storybook/test';
+import { delay, http, HttpResponse } from 'msw';
import { BibleCard } from './bible-card';
+import mockPassages from '../test/mock-data/passages.json';
+import mockBibles from '../test/mock-data/bibles.json';
+
+const LOADING_DELAY = 1000;
+
+const delayedHandlers = [
+ http.get('*/v1/bibles/:id/passages/*', async ({ params }) => {
+ await delay(LOADING_DELAY);
+
+ const id = String(params.id);
+ if (id === '111') {
+ return HttpResponse.json(mockPassages['LUK.1.39-45.NIV']);
+ }
+
+ if (id === '1588') {
+ return HttpResponse.json(mockPassages['LUK.1.39-45.AMP']);
+ }
+
+ return new HttpResponse(null, { status: 404 });
+ }),
+ http.get('*/v1/bibles/:id', async ({ params }) => {
+ await delay(LOADING_DELAY);
+
+ const id = String(params.id);
+ const bible = mockBibles.individual[id as keyof typeof mockBibles.individual];
+ if (bible) {
+ return HttpResponse.json(bible);
+ }
+
+ return new HttpResponse(null, { status: 404 });
+ }),
+];
const meta = {
title: 'Components/BibleCard',
@@ -117,6 +150,74 @@ export const WithVersionPicker: Story = {
},
};
+export const Loading: Story = {
+ args: {
+ reference: 'LUK.1.39-45',
+ versionId: 111,
+ },
+ tags: ['integration'],
+ parameters: {
+ msw: {
+ handlers: delayedHandlers,
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ const loadingSkeleton = await canvas.findByRole('status', {
+ name: /loading bible verse/i,
+ });
+ await expect(loadingSkeleton).toHaveAttribute('aria-busy', 'true');
+ },
+};
+
+export const LoadingWithVersionPicker: Story = {
+ args: {
+ reference: 'LUK.1.39-45',
+ versionId: 111,
+ showVersionPicker: true,
+ },
+ tags: ['integration'],
+ parameters: {
+ msw: {
+ handlers: delayedHandlers,
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ const loadingSkeleton = await canvas.findByRole('status', {
+ name: /loading bible verse/i,
+ });
+ await expect(loadingSkeleton).toHaveAttribute('aria-busy', 'true');
+ },
+};
+
+export const LoadingDarkMode: Story = {
+ args: {
+ reference: 'LUK.1.39-45',
+ versionId: 111,
+ showVersionPicker: true,
+ },
+ globals: {
+ theme: 'dark',
+ },
+ tags: ['integration'],
+ parameters: {
+ msw: {
+ handlers: delayedHandlers,
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ const loadingSkeleton = await canvas.findByRole('status', {
+ name: /loading bible verse/i,
+ });
+ await expect(loadingSkeleton).toHaveAttribute('aria-busy', 'true');
+ },
+};
+
export const RealAPI: Story = {
args: {
reference: 'LUK.1.39-45',
diff --git a/packages/ui/src/components/bible-card.tsx b/packages/ui/src/components/bible-card.tsx
index 85491422..ca6ef517 100644
--- a/packages/ui/src/components/bible-card.tsx
+++ b/packages/ui/src/components/bible-card.tsx
@@ -2,6 +2,7 @@ import { usePassage, useVersion, useTheme } from '@youversion/platform-react-hoo
import { BibleTextView } from './verse';
import { BibleAppLogoLockup } from './bible-app-logo-lockup';
import { BibleVersionPicker } from './bible-version-picker';
+import { BibleCardSkeleton } from './bible-card-skeleton';
import { Button } from './ui/button';
import { useState } from 'react';
import { SOURCE_SERIF_FONT } from '@/lib/verse-html-utils';
@@ -19,8 +20,12 @@ export function BibleCard({
showVersionPicker = false,
}: BibleCardProps): React.ReactNode {
const [versionNum, setVersionNum] = useState(versionId);
- const { version } = useVersion(versionNum);
- const { passage } = usePassage({
+ const { version, loading: versionLoading } = useVersion(versionNum);
+ const {
+ passage,
+ loading: passageLoading,
+ error,
+ } = usePassage({
versionId: versionNum,
usfm: reference,
include_headings: true,
@@ -29,6 +34,10 @@ export function BibleCard({
const providerTheme = useTheme();
const theme = background || providerTheme;
+ if (versionLoading || passageLoading) {
+ return ;
+ }
+
return (
diff --git a/packages/ui/src/components/ui/skeleton.tsx b/packages/ui/src/components/ui/skeleton.tsx
new file mode 100644
index 00000000..f4b223b8
--- /dev/null
+++ b/packages/ui/src/components/ui/skeleton.tsx
@@ -0,0 +1,14 @@
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+
+function Skeleton({ className, ...props }: React.ComponentProps<'div'>): React.ReactElement {
+ return (
+
+ );
+}
+
+export { Skeleton };
diff --git a/packages/ui/src/components/verse.tsx b/packages/ui/src/components/verse.tsx
index 0eef4582..f65d3fb0 100644
--- a/packages/ui/src/components/verse.tsx
+++ b/packages/ui/src/components/verse.tsx
@@ -96,11 +96,7 @@ const VerseFootnoteButton = memo(function VerseFootnoteButton({
const VERSE_UNAVAILABLE_MESSAGE = 'Your previously selected Bible verse is unavailable.';
-/**
- * Displays a verse-unavailable error message with a circular exclamation
- * icon and descriptive text.
- */
-function VerseUnavailableMessage(): React.ReactElement {
+export function VerseUnavailableMessage(): React.ReactElement {
return (
void;
highlightedVerses?: Record;
+ passage?: { content: string; reference?: string } | null;
+ loading?: boolean;
+ error?: Error | null;
};
/**
@@ -349,13 +348,29 @@ export const BibleTextView = ({
selectedVerses,
onVerseSelect,
highlightedVerses,
+ passage: externalPassage,
+ loading: externalLoading,
+ error: externalError,
}: BibleTextViewProps): React.ReactElement => {
- const { passage, loading, error } = usePassage({
+ const hasExternalState =
+ externalPassage !== undefined || externalLoading !== undefined || externalError !== undefined;
+
+ const {
+ passage: fetchedPassage,
+ loading: fetchedLoading,
+ error: fetchedError,
+ } = usePassage({
versionId,
usfm: reference,
include_headings: true,
include_notes: true,
+ options: { enabled: !hasExternalState },
});
+
+ const passage = hasExternalState ? (externalPassage ?? null) : fetchedPassage;
+ const loading = hasExternalState ? Boolean(externalLoading) : fetchedLoading;
+ const error = hasExternalState ? (externalError ?? null) : fetchedError;
+
const providerTheme = useTheme();
const currentTheme = theme || providerTheme;