Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/many-lamps-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@youversion/platform-react-ui': minor
'@youversion/platform-core': minor
'@youversion/platform-react-hooks': minor
---

Added loading skeleton to bible card
45 changes: 45 additions & 0 deletions packages/ui/src/components/bible-card-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section
data-yv-sdk
data-yv-theme={background}
role="status"
aria-live="polite"
aria-busy="true"
aria-label="Loading Bible verse"
className="yv:flex yv:flex-col yv:bg-card yv:p-6 yv:max-w-md yv:rounded-2xl"
>
<span className="yv:sr-only">Loading Bible verse</span>

<div className="yv:flex yv:justify-between yv:items-center">
<Skeleton className="yv:h-6 yv:w-24 yv:rounded-[5px]" />
{showVersionPicker ? <Skeleton className="yv:h-10 yv:w-18 yv:rounded-full" /> : null}
</div>

<div className="yv:mt-10">
<Skeleton className="yv:h-32 yv:w-full yv:rounded-[5px]" />
</div>

<div className="yv:grid yv:grid-cols-[1fr_auto] yv:gap-4 yv:items-center yv:mt-10">
<div className="yv:space-y-2 yv:w-64 yv:flex yv:flex-col yv:gap-2 yv:rounded-sm">
<Skeleton className="yv:h-4 yv:w-full yv:rounded-[5px]" />
<Skeleton className="yv:h-4 yv:w-40 yv:rounded-[5px]" />
</div>
<div className="yv:justify-self-end">
<BibleAppLogoLockup fontSize={12} />
</div>
</div>
</section>
);
}
101 changes: 101 additions & 0 deletions packages/ui/src/components/bible-card.stories.tsx
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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',
Expand Down
15 changes: 13 additions & 2 deletions packages/ui/src/components/bible-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -29,6 +34,10 @@ export function BibleCard({
const providerTheme = useTheme();
const theme = background || providerTheme;

if (versionLoading || passageLoading) {
return <BibleCardSkeleton showVersionPicker={showVersionPicker} background={theme} />;
}

return (
<section
data-yv-sdk
Expand Down Expand Up @@ -71,6 +80,8 @@ export function BibleCard({
fontFamily={SOURCE_SERIF_FONT}
reference={reference}
versionId={versionNum}
passage={passage ? { content: passage.content, reference: passage.reference } : null}
error={error}
/>

<div className="yv:grid yv:grid-cols-[1fr_auto] yv:gap-4 yv:items-center yv:mt-4">
Expand Down
14 changes: 14 additions & 0 deletions packages/ui/src/components/ui/skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as React from 'react';
import { cn } from '@/lib/utils';

function Skeleton({ className, ...props }: React.ComponentProps<'div'>): React.ReactElement {
return (
<div
className={cn('yv:animate-pulse yv:rounded-md yv:bg-accent', className)}
aria-hidden="true"
{...props}
/>
);
}

export { Skeleton };
27 changes: 21 additions & 6 deletions packages/ui/src/components/verse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
role="alert"
Expand Down Expand Up @@ -332,6 +328,9 @@ export type BibleTextViewProps = {
selectedVerses?: number[];
onVerseSelect?: (verses: number[]) => void;
highlightedVerses?: Record<number, boolean>;
passage?: { content: string; reference?: string } | null;
loading?: boolean;
error?: Error | null;
};

/**
Expand All @@ -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;

Expand Down