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
19 changes: 19 additions & 0 deletions .changeset/bible-card-max-width.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@youversion/platform-react-ui': minor
---

`BibleCard` now caps its painted shell at 700 px by default — one measure that
the header, scripture, and footer share, matching the Swift SDK — instead of
stretching to fill a wide host. The card still fills a narrower host and centers
itself in a wider one.

A new optional `maxWidth?: number | '100%'` prop controls the cap:

- Omit it for the default 700 px shell; the inner column fills that shell (only
the card's padding is the inset).
- Pass a number to cap the shell at that many CSS px; the inner column fills it.
- Pass `"100%"` for a full-bleed shell that fills its parent; the inner text
column then stays capped at 600 px so scripture keeps a column.

Breaking for full-bleed layouts: hosts that relied on `BibleCard` growing to
fill a wide container must now pass `maxWidth="100%"`.
23 changes: 23 additions & 0 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,29 @@ By default the version picker offers Bible versions in every available language.

Language tags are BCP 47 (`en`, `es`, `zh-Hans`). Version ids are YouVersion Bible version ids. An unusable id is refused everywhere — lists, picker, and `versionId` on reader/card/text/VOTD — and surfaces the existing forbidden error. Core-only hosts can set the same lists on `YouVersionPlatformConfiguration`.

### `BibleCard` width

`BibleCard` caps its painted shell at **700 px by default** — one measure that the header, scripture, and footer share, matching the Swift SDK. The card still fills a narrower host (`width: 100%`) and centers itself in a wider one. Control the cap with the optional `maxWidth` prop:

| `maxWidth` | Painted shell | Inner text column |
|------------|---------------|-------------------|
| _omitted_ (default) | caps at 700 px | fills the shell (only the card's padding is the inset) |
| a number, e.g. `480` | caps at that many CSS px | fills the shell |
| `"100%"` | full-bleed — fills the parent | stays capped at 600 px so scripture keeps a column |

```tsx
{/* Default: shell caps at 700, centered on a wide host */}
<BibleCard reference="JHN.3.16" versionId={3034} />

{/* Tighter shell */}
<BibleCard reference="JHN.3.16" versionId={3034} maxWidth={480} />

{/* Full-bleed shell (the inner text column still caps at 600) */}
<BibleCard reference="JHN.3.16" versionId={3034} maxWidth="100%" />
```

Only a number or `"100%"` is accepted. If your layout needs a full-bleed card shell, pass `maxWidth="100%"` — otherwise the shell will not grow past 700 px.

## Styling

All component CSS is automatically injected when you wrap your app with `YouVersionProvider` — no extra imports or build steps needed. Under the hood, it uses React 19's [`<style precedence>`](https://react.dev/reference/react-dom/components/style) to hoist styles into `<head>` with built-in deduplication and SSR/Suspense support.
Expand Down
68 changes: 58 additions & 10 deletions packages/ui/src/components/bible-card.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ const meta = {
control: 'boolean',
description: 'toggle version picker',
},
maxWidth: {
control: 'text',
description:
'Caps the painted card shell. Omit for the default 700 px (one measure, ' +
'like Swift; the inner column fills that shell). Pass a number for a ' +
'custom px cap, or "100%" for a full-bleed shell (inner text column ' +
'stays capped at 600 px).',
},
},
} satisfies Meta<typeof BibleCard>;

Expand Down Expand Up @@ -80,23 +88,63 @@ export const WideContainer: Story = {

const card = canvasElement.querySelector('section[data-yv-sdk][data-yv-theme]');
const contentGroup = canvasElement.querySelector('section[data-yv-sdk][data-yv-theme] > div');
const bibleText = canvasElement.querySelector('[data-slot="yv-bible-renderer"]');
const host = canvasElement.querySelector('div[style]');

await expect(card).not.toBeNull();
await expect(contentGroup).not.toBeNull();
await expect(bibleText).not.toBeNull();

const cardWidth = card?.getBoundingClientRect().width ?? 0;
const contentGroupRect = contentGroup?.getBoundingClientRect();
const cardRect = card?.getBoundingClientRect();
const leftWhitespace = (contentGroupRect?.left ?? 0) - (cardRect?.left ?? 0);
const rightWhitespace = (cardRect?.right ?? 0) - (contentGroupRect?.right ?? 0);
const bibleTextWidth = bibleText?.getBoundingClientRect().width ?? 0;
const cardWidth = cardRect?.width ?? 0;
const contentGroupRect = contentGroup?.getBoundingClientRect();
const hostRect = host?.getBoundingClientRect();
const leftWhitespace = (cardRect?.left ?? 0) - (hostRect?.left ?? 0);
const rightWhitespace = (hostRect?.right ?? 0) - (cardRect?.right ?? 0);

// Default (no maxWidth): the painted shell caps at 700 (one measure, like
// Swift), and is centered in the wide host. The inner column fills that
// shell — no 600 cap on this path — so it is wider than 600.
await expect(cardWidth).toBeLessThanOrEqual(700);
await expect(cardWidth).toBeGreaterThan(600);
await expect(contentGroupRect?.width ?? 0).toBeGreaterThan(600);
await expect(Math.abs(leftWhitespace - rightWhitespace)).toBeLessThanOrEqual(1);
},
};

export const FullBleed: Story = {
args: {
reference: 'LUK.1.39-45',
versionId: 111,
maxWidth: '100%',
},
tags: ['integration'],
parameters: {
layout: 'fullscreen',
},
render: (args) => (
<div className="yv:p-8" style={{ width: 900 }}>
<BibleCard {...args} />
</div>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);

await waitFor(async () => {
await expect(canvas.getByText(/at that time mary got ready/i)).toBeInTheDocument();
});

const card = canvasElement.querySelector('section[data-yv-sdk][data-yv-theme]');
const contentGroup = canvasElement.querySelector('section[data-yv-sdk][data-yv-theme] > div');

await expect(card).not.toBeNull();
await expect(contentGroup).not.toBeNull();

const cardWidth = card?.getBoundingClientRect().width ?? 0;
const contentGroupWidth = contentGroup?.getBoundingClientRect().width ?? 0;

// maxWidth="100%": the painted shell fills the wide host (Come and See), but
// the inner text column stays capped at 600 so scripture keeps a column.
await expect(cardWidth).toBeGreaterThan(800);
await expect(contentGroupRect?.width ?? 0).toBeLessThanOrEqual(600);
await expect(bibleTextWidth).toBeLessThanOrEqual(600);
await expect(Math.abs(leftWhitespace - rightWhitespace)).toBeLessThanOrEqual(1);
await expect(contentGroupWidth).toBeLessThanOrEqual(600);
},
};

Expand Down
48 changes: 46 additions & 2 deletions packages/ui/src/components/bible-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ function renderCard(
versionId?: number;
highlights?: Highlight[];
onVersionChange?: (id: number) => void;
maxWidth?: number | '100%';
} = {},
) {
const {
Expand All @@ -72,6 +73,7 @@ function renderCard(
versionId = 3034,
highlights,
onVersionChange,
maxWidth,
} = extra;
return render(
<HookOverrideProvider
Expand All @@ -86,6 +88,7 @@ function renderCard(
onFootnotePress={onFootnotePress}
highlights={highlights}
onVersionChange={onVersionChange}
maxWidth={maxWidth}
/>
</HookOverrideProvider>,
);
Expand Down Expand Up @@ -161,19 +164,60 @@ describe('BibleCard - Delayed spinner', () => {
);
});

it('should let the card fill its container while centering the content group', () => {
it('omit maxWidth: section caps at 700, inner column fills it (no 600 cap), p-6 stays', () => {
const { container } = renderCard(passageResult({ passage: mockPassage, loading: false }));
const card = container.querySelector('section');
const contentGroup = container.querySelector('section > div');
const bibleTextView = container.querySelector('[data-slot="yv-bible-renderer"]')?.parentElement;

// Section stays width: 100% and centers, but caps at Swift's 700 measure.
expect(card).toHaveClass('yv:w-full');
expect(card).not.toHaveClass('yv:max-w-md');
expect(card).toHaveClass('yv:box-border');
expect(contentGroup).toHaveClass('yv:card-content');
expect(card).toHaveClass('yv:p-6');
expect(card).toHaveStyle({ maxWidth: '700px', marginInline: 'auto' });
// Inner column fills the section — no 600 cap on this path.
expect(contentGroup).not.toHaveClass('yv:card-content');
expect(contentGroup).toHaveClass('yv:w-full');
expect(bibleTextView).not.toHaveClass('yv:max-w-[600px]');
});

it('maxWidth number: section caps at that px, inner column fills it (no 600 cap)', () => {
const { container } = renderCard(passageResult({ passage: mockPassage, loading: false }), {
maxWidth: 480,
});
const card = container.querySelector('section');
const contentGroup = container.querySelector('section > div');

expect(card).toHaveClass('yv:w-full');
expect(card).toHaveStyle({ maxWidth: '480px', marginInline: 'auto' });
expect(contentGroup).not.toHaveClass('yv:card-content');
expect(contentGroup).toHaveClass('yv:w-full');
});

it('maxWidth="100%": section fills the parent, inner column keeps the 600 cap', () => {
const { container } = renderCard(passageResult({ passage: mockPassage, loading: false }), {
maxWidth: '100%',
});
const card = container.querySelector('section');
const contentGroup = container.querySelector('section > div');

expect(card).toHaveClass('yv:w-full');
expect(card).toHaveStyle({ maxWidth: '100%', marginInline: 'auto' });
// Full-bleed shell keeps a 600 text column (Come and See / YPE-2573).
expect(contentGroup).toHaveClass('yv:card-content');
});

it('parent narrower than the cap: section stays 100% of that parent', () => {
const { container } = renderCard(passageResult({ passage: mockPassage, loading: false }), {
maxWidth: 480,
});
const card = container.querySelector('section');

// The cap is a ceiling; the section still fills a narrower parent.
expect(card).toHaveClass('yv:w-full');
});

it('should hide inline verse numbers in the bible renderer', () => {
const { container } = renderCard(passageResult({ passage: mockPassage, loading: false }));
const bibleRenderer = container.querySelector('[data-slot="yv-bible-renderer"]');
Expand Down
37 changes: 36 additions & 1 deletion packages/ui/src/components/bible-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ import { AnimatedHeight } from './animated-height';
type PassageResult = ReturnType<typeof usePassage>;
type VersionResult = ReturnType<typeof useVersion>;

/**
* Default cap for the painted card shell, in CSS px. Matches Swift's
* `BibleCardView.maximumContentWidth` / `BibleReaderView.readerMaxWidth` so the
* React card is one 700 measure, not a 700 shell around a 600 column.
*/
const DEFAULT_MAX_WIDTH = 700;

export type BibleCardProps = {
reference: string;
versionId?: number;
Expand All @@ -41,6 +48,23 @@ export type BibleCardProps = {
* references paint the whole chapter.
*/
highlights?: Highlight[];
/**
* Caps the painted card shell (the `<section>`). CSS-px number or `'100%'`.
*
* This is one measure, matching Swift's `BibleCardView.maximumContentWidth`:
* header, scripture, and footer share it, and only the card's `p-6` is the
* inset. The section stays `width: 100%` and is centered with
* `margin-inline: auto`, so it fills a narrow host and caps on a wide one.
*
* - Omit: the section caps at 700. The inner column fills the section.
* - Number: that value (in CSS px) caps the section. The inner column fills it.
* - `'100%'`: the section fills its parent (full-bleed shell). The inner text
* column then stays capped at 600 so scripture does not run edge to edge.
*
* Only a number or `'100%'` is accepted — `'none'`, `'700px'`, `'65ch'`, and
* other strings are rejected by the type.
*/
maxWidth?: number | '100%';
};

/**
Expand Down Expand Up @@ -139,6 +163,7 @@ export function BibleCard({
onVersionPickerPress,
onFootnotePress,
highlights,
maxWidth,
}: BibleCardProps): React.ReactNode {
// Controlled only when both versionId + onVersionChange are provided.
// versionId alone seeds uncontrolled state, preserving backwards compatibility
Expand Down Expand Up @@ -168,13 +193,23 @@ export function BibleCard({
const isRefetching = passageLoading && passage !== null;
const showSpinner = useDelayedLoading(isRefetching);

// The painted <section> owns the cap (one measure, like Swift). It stays
// width: 100% and is centered, so it fills a narrow host and caps on a wide
// one. A number is CSS px; '100%' makes the shell full-bleed.
const isFullBleed = maxWidth === '100%';
const sectionMaxWidth = maxWidth === undefined ? DEFAULT_MAX_WIDTH : maxWidth;
// Default / number: the inner column fills the section (only p-6 is the inset).
// '100%': keep the shared 600 cap so full-bleed shells keep a text column.
const contentClassName = isFullBleed ? 'yv:card-content' : 'yv:w-full';

return (
<section
data-yv-sdk
data-yv-theme={theme}
className="yv:w-full yv:flex yv:flex-col yv:grow yv:bg-card yv:p-6 yv:rounded-2xl yv:box-border"
style={{ maxWidth: sectionMaxWidth, marginInline: 'auto' }}
>
<div className="yv:card-content">
<div className={contentClassName}>
<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
Expand Down
Loading