Skip to content
Open
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/biblecard-single-error-alert.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@youversion/platform-react-ui': patch
---

Fix the `BibleCard` error state announcing two alerts, and keep the version picker usable while an error is showing. The "Error" label stays in the header slot but drops its `role="alert"` and `aria-live`, leaving the message block in the card body as the only alert region. The picker no longer disappears on error, so a 404 has an in-card fix: switch to a version that carries the passage.

The shared message block also drops a redundant `aria-live` and hides its icon with `aria-hidden`, so `VerseOfTheDay` and standalone `BibleTextView` pick up the same accessibility fixes. Their visible text is unchanged, and neither gains an "Error" label. The eight status-aware messages, their six locales, and how errors are derived are untouched.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"drops a redundant aria-live" — same correction as the verse.tsx comment: it wasn't redundant, it was an override, and removing it moves VerseOfTheDay and standalone BibleTextView from polite to assertive announcements.

docs/review-guidelines.md asks whether the code actually implements what the PR description claims, and whether consumer-visible changes are identified in the changeset. Since this ships to consumers of both those components, the changeset should say the announcement becomes assertive rather than describing it as a no-op cleanup.

23 changes: 19 additions & 4 deletions packages/ui/src/components/bible-card.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ export const Error: Story = {
args: {
reference: 'LUK.1.39-45',
versionId: 111,
showVersionPicker: true,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes an integration-tagged story hit the real API.

showVersionPicker: true mounts BibleVersionPicker.Root, which calls useLanguages and useVersions unconditionally in the component body (bible-version-picker.tsx:311, :316, :317) — not lazily when the popover opens. This story's msw handlers only cover */v1/bibles/111 and the passage, and .storybook/preview.tsx:28 sets onUnhandledRequest: 'warn', so the language and version-list requests fall through to the live host instead of failing loudly.

Adding handlers for the languages/versions endpoints fixes this and has a second benefit: it makes the manual verification step in the PR description ("open the version picker and select a different version… make sure the card recovers") actually reproducible from this story, which it currently isn't.

},
tags: ['integration'],
parameters: {
Expand All @@ -206,12 +207,26 @@ export const Error: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);

// The header slot carries the "Error" label; the body block is the one alert.
await waitFor(async () => {
await expect(canvas.getByRole('heading', { level: 2, name: /error/i })).toBeInTheDocument();
const errorMessages = canvas.getAllByText(
'The Bible service is having trouble right now. Please try again in a moment.',
);
await expect(errorMessages.length).toBeGreaterThan(0);
});

const alerts = canvas.getAllByRole('alert');

await expect(alerts).toHaveLength(1);
await expect(alerts[0]).toHaveTextContent(
'The Bible service is having trouble right now. Please try again in a moment.',
);

// The picker is the in-card recovery path: a 404 is fixed by switching versions.
const versionPickerButton = await canvas.findByRole('button', {
name: /change bible version/i,
});

await waitFor(async () => {
await expect(versionPickerButton).toBeEnabled();
await expect(versionPickerButton).toHaveTextContent(/NIV/i);
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we assert that changing the version clears the error in this test?

},
};
97 changes: 95 additions & 2 deletions packages/ui/src/components/bible-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,17 @@ import { render, act, within, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BibleCard } from './bible-card';
import type { FootnoteData } from './verse';
import { usePassage, useVersion, useTheme } from '@youversion/platform-react-hooks';
import type { BiblePassage, BibleVersion } from '@youversion/platform-core';
import {
useFilteredVersions,
useLanguage,
useLanguages,
useOrganizations,
usePassage,
useTheme,
useVersion,
useVersions,
} from '@youversion/platform-react-hooks';
import type { BiblePassage, BibleVersion, Language } from '@youversion/platform-core';

vi.mock('@youversion/platform-react-hooks');

Expand Down Expand Up @@ -156,6 +165,90 @@ describe('BibleCard - Delayed spinner', () => {
});
});

describe('BibleCard - Error state', () => {
function createError(message: string, status?: number): Error {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two smaller things on the new test block:

createError is byte-identical to the one added in verse.test.tsx:888. Worth pulling into a shared test helper rather than keeping two copies in sync.

These five jsdom tests largely restate the updated Error play function (one alert, the message text, the picker enabled). packages/ui/AGENTS.md → TESTING says to prefer Storybook with play for UI component tests. The picker test in particular hand-mocks five hooks (useLanguages, useLanguage, useVersions, useFilteredVersions, useOrganizations) that are BibleVersionPicker's internals — so it breaks whenever that component's data dependencies change, which is exactly the coupling the Storybook preference avoids.

return Object.assign(new Error(message), status === undefined ? {} : { status });
}

beforeEach(() => {
vi.mocked(useTheme).mockReturnValue('light');
vi.mocked(useVersion).mockReturnValue({
version: mockVersion,
loading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(usePassage).mockReturnValue({
passage: null,
loading: false,
error: createError('Request failed with status 503', 503),
refetch: vi.fn(),
});
});

it('should render exactly one alert region', () => {
const { container } = render(<BibleCard reference="JHN.3.16" versionId={3034} />);

expect(within(container).getAllByRole('alert')).toHaveLength(1);
});

it('should show the status message in that one alert region', () => {
const { container } = render(<BibleCard reference="JHN.3.16" versionId={3034} />);
const alert = within(container).getByRole('alert');

expect(alert).toHaveTextContent(
'The Bible service is having trouble right now. Please try again in a moment.',
);
});

it('should render the error heading in the header slot', () => {
const { container } = render(<BibleCard reference="JHN.3.16" versionId={3034} />);

expect(within(container).getByRole('heading', { level: 2 })).toHaveTextContent('Error');
});

it('should not render a loading spinner while an error is set', () => {
const { container } = render(<BibleCard reference="JHN.3.16" versionId={3034} />);

expect(within(container).queryByRole('status')).toBeNull();
});

it('should keep the version picker usable so a bad version can be swapped', () => {
// The version picker mounts its own hook tree. The file-level auto-mock
// returns undefined for each one, so give them values here.
vi.mocked(useLanguages).mockReturnValue({
languages: { data: [] as Language[], next_page_token: null },
loading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useLanguage).mockReturnValue({
language: { id: 'en', language: 'English', display_names: { en: 'English' } } as Language,
loading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useVersions).mockReturnValue({
versions: { data: [], next_page_token: null },
loading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useFilteredVersions).mockReturnValue([]);
vi.mocked(useOrganizations).mockReturnValue({ organizations: new Map() });

const { container } = render(
<BibleCard reference="JHN.3.16" versionId={3034} showVersionPicker />,
);

const picker = within(container).getByRole('button', { name: /change bible version/i });

expect(picker).toBeInTheDocument();
expect(picker).toBeEnabled();
expect(picker).toHaveTextContent('BSB');
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we assert that a successful version change clears the error in this test or create another separate test to assert this?

});

describe('BibleCard - onFootnotePress callback', () => {
const mockPassageWithFootnote: BiblePassage = {
id: 'JHN.1',
Expand Down
27 changes: 21 additions & 6 deletions packages/ui/src/components/bible-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,21 @@ export type BibleCardProps = {
onFootnotePress?: (data: FootnoteData) => void;
};

/**
* The "Error" label for the header slot.
*
* It matches `BibleCardHeaderReference` exactly. The card already renders an
* `<h2>` in this slot for the passage reference, so this injects no new heading
* level into the host page's outline. It carries no `role="alert"` and no
* `aria-live`: the body block stays the single alert region, so screen readers
* announce one alert.
*/
function BibleCardHeaderError(): React.ReactNode {
const { t } = useTranslation(undefined, { i18n });
return (
<div className="yv:flex yv:flex-col yv:gap-2" role="alert" aria-live="polite">
<h2 className="yv:font-bold yv:tracking-widest yv:text-xs yv:uppercase yv:text-foreground">
{t('errorHeading')}
</h2>
</div>
<h2 className="yv:font-bold yv:tracking-widest yv:text-xs yv:uppercase yv:text-foreground">
{t('errorHeading')}
</h2>
);
}

Expand Down Expand Up @@ -151,6 +158,10 @@ export function BibleCard({
>
<div className="yv:card-content">
<div className="yv:flex yv:w-full yv:justify-between yv:items-center yv:mb-4">
{/*
The error branch stays separate rather than folding into the loading
branch, which would spin forever on error.
*/}
{passage && !passageError ? (
<div className="yv:grow yv:flex yv:items-center yv:gap-1.5">
<BibleCardHeaderReference passage={passage} version={version} />
Expand All @@ -164,7 +175,11 @@ export function BibleCard({
<LoaderIcon className="yv:size-3 yv:animate-spin yv:text-muted-foreground" />
)}

{showVersionPicker && !passageError ? (
{/*
The picker stays available during an error. A 404 means the passage
is not in the selected version, so switching versions is the fix.
*/}
{showVersionPicker ? (
<BibleCardVersionPicker
versionId={versionNum}
onVersionChange={setVersionNum}
Expand Down
28 changes: 28 additions & 0 deletions packages/ui/src/components/verse.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1044,6 +1044,34 @@ describe('BibleTextView - Error messaging', () => {
});
});

it('should render one alert region with a hidden icon and no heading line', async () => {
const { getAllByRole, getByRole } = render(
<BibleTextView
reference="JHN.3.16"
versionId={3034}
passageState={{
passage: null,
loading: false,
error: createError('Request failed with status 503', 503),
}}
/>,
);

await waitFor(() => {
expect(getByRole('alert')).toHaveTextContent(
'The Bible service is having trouble right now. Please try again in a moment.',
);
});

const alert = getByRole('alert');

expect(getAllByRole('alert')).toHaveLength(1);
expect(alert).not.toHaveAttribute('aria-live');
expect(alert.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
// Standalone BibleTextView has no header slot, so no "Error" label renders.
expect(alert).not.toHaveTextContent('Error');
});

it('should prioritize 5xx errors over "not found" text in the message', async () => {
const { getByRole } = render(
<BibleTextView
Expand Down
11 changes: 7 additions & 4 deletions packages/ui/src/components/verse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -251,17 +251,20 @@ const VerseFootnoteButton = memo(function VerseFootnoteButton({
});

/**
* Displays a verse-unavailable error message with a circular exclamation
* icon and descriptive text.
* Displays a verse-unavailable error message as one alert region: a circular
* exclamation icon and the status-aware message.
*
* The "Error" label lives in the BibleCard header slot, not here, so this block
* stays a single sentence. `role="alert"` already implies an assertive live
* region, so no `aria-live` is set, and the icon is hidden from screen readers.
*/
function VerseUnavailableMessage({ message }: { message: string }): React.ReactElement {
return (
<div
role="alert"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing aria-live="polite" here isn't a de-duplication — it's a politeness change.

role="alert" implies aria-live="assertive". The explicit aria-live="polite" was overriding that implicit value down to polite, so deleting it doesn't leave the behavior unchanged — it restores assertive, and the message now interrupts whatever the screen reader is currently speaking.

The doc comment added in this same hunk actually states this correctly ("role="alert" already implies an assertive live region"), which is what makes the "duplicate"/"redundant" framing in the PR body and changeset inconsistent with the code.

This lands on VerseOfTheDay and standalone BibleTextView too, where the PR promises only that "the text that they show does not change" — the announcement urgency does change for both.

If assertive is the intent, that's defensible for a failed load, but it should be stated as the deliberate change it is. If it isn't intended, keep aria-live="polite" — it still wins over the implicit assertive.

aria-live="polite"
className="yv:flex yv:items-center yv:justify-center yv:gap-2.5 yv:px-3 yv:py-2.5 yv:text-foreground"
>
<ExclamationCircle className="yv:size-5 yv:shrink-0 yv:text-foreground" />
<ExclamationCircle className="yv:size-5 yv:shrink-0 yv:text-foreground" aria-hidden="true" />
<p className="yv:m-0 yv:text-[13px] yv:font-medium yv:leading-tight">{message}</p>
</div>
);
Expand Down
Loading