Skip to content
Merged
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
44 changes: 28 additions & 16 deletions apps/mobile/src/components/pr-review/pr-review-screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,22 +127,11 @@ vi.mock('@/lib/pr-review/recent-prs', () => ({
markRecentPrFailed: vi.fn(),
}));

vi.mock('@/components/screen-header', () => {
// The screen passes `headerRight` as a named slot prop. We render it
// alongside the `children` slot so the tree walk can find the
// Submit-review Button inside.
const MockScreenHeader = (props: {
headerRight?: React.ReactNode;
children?: React.ReactNode;
}): React.ReactElement =>
React.createElement(
'ScreenHeader',
{ hasHeaderRight: props.headerRight != null },
props.headerRight,
props.children
);
return { ScreenHeader: MockScreenHeader };
});
// A host-node mock keeps every prop the screen passes (including
// `eyebrowNumberOfLines`) on the node the `findElement` walk inspects, so the
// test can assert the header's contract directly. `headerRight` stays a named
// slot prop, which the walker follows into.
vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' }));
vi.mock('@/components/pr-review/merge/pr-merge-partial-success-banner', () => ({
PrMergePartialSuccessBanner: 'PrMergePartialSuccessBanner',
}));
Expand Down Expand Up @@ -666,3 +655,26 @@ describe('PrReviewScreen recents backfill per provider', () => {
});
});
});

describe('PrReviewScreen header eyebrow cap', () => {
beforeEach(() => {
prQueryResult = {
data: undefined,
isLoading: true,
isError: false,
isFetching: false,
};
});

it('caps the repository eyebrow to a single line so it cannot wrap onto the title', () => {
// eslint-disable-next-line new-cap
const element = PrReviewScreen({ owner: 'octocat', repo: 'hello', number: 7 });
const header = findElement({
node: element,
type: 'ScreenHeader',
prop: 'eyebrowNumberOfLines',
value: 1,
});
expect(header).not.toBeNull();
});
});
1 change: 1 addition & 0 deletions apps/mobile/src/components/pr-review/pr-review-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ export function PrReviewScreen({ owner, repo, number }: PrReviewScreenProps) {
: t('prReview.screen.title', { number })
}
eyebrow={`${owner}/${repo}`}
eyebrowNumberOfLines={1}
headerRight={
<View className="flex-row items-center gap-1">
{webUrl ? (
Expand Down
22 changes: 22 additions & 0 deletions apps/mobile/src/components/screen-header.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -679,4 +679,26 @@ describe('ScreenHeader mounted', () => {
expect(iosStyle).toEqual({ paddingTop: 32 });
expect(androidStyle).toEqual(iosStyle);
});

it('caps the eyebrow to one line with a middle ellipsis when eyebrowNumberOfLines is set', () => {
const renderer = renderHeader({
title: '#1',
eyebrow: 'KILO-STUB/DISCUSSION-MIXED',
eyebrowNumberOfLines: 1,
});

const eyebrow = renderer.root.findByType('Eyebrow');
expect(eyebrow.props.numberOfLines).toBe(1);
expect(eyebrow.props.ellipsizeMode).toBe('middle');
expect(eyebrow.children).toEqual(['KILO-STUB/DISCUSSION-MIXED']);
});

it('keeps the reserved eyebrow placeholder on the same single capped line', () => {
const renderer = renderHeader({ reserveEyebrow: true, eyebrowNumberOfLines: 1 });

const eyebrow = renderer.root.findByType('Eyebrow');
expect(eyebrow.props.numberOfLines).toBe(1);
expect(eyebrow.props.ellipsizeMode).toBe('middle');
expect(eyebrow.children).toEqual(['\u00A0']);
});
});
13 changes: 11 additions & 2 deletions apps/mobile/src/components/screen-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ type ScreenHeaderProps = {
titleContent?: React.ReactNode;
/** Line cap for the title, at most {@link MAX_TITLE_LINES}. */
titleNumberOfLines?: number;
/**
* Cap the eyebrow to this many lines, overriding the header's default single
* line. When set the eyebrow ellipsizes in the middle: the repo name is the
* distinguishing tail of a path, so a middle ellipsis keeps more of it than a
* tail ellipsis. Left undefined every caller keeps the default one-line tail
* ellipsis, so a long repository label never reflows the header.
*/
eyebrowNumberOfLines?: number;
/** Reserve the title's line cap so state changes do not move the screen body. */
reserveTitleSpace?: boolean;
/** Optional mono-uppercase line above the title. */
Expand Down Expand Up @@ -90,6 +98,7 @@ export function ScreenHeader({
title,
titleContent,
titleNumberOfLines = 2,
eyebrowNumberOfLines,
reserveTitleSpace = false,
eyebrow,
reserveEyebrow = false,
Expand Down Expand Up @@ -224,8 +233,8 @@ export function ScreenHeader({
{eyebrow || reserveEyebrow ? (
<Eyebrow
className={cn('mb-0.5', centerTitle && 'text-center', !eyebrow && 'opacity-0')}
numberOfLines={1}
ellipsizeMode="tail"
numberOfLines={eyebrowNumberOfLines ?? 1}
ellipsizeMode={eyebrowNumberOfLines === undefined ? 'tail' : 'middle'}
accessible={Boolean(eyebrow)}
accessibilityElementsHidden={!eyebrow}
importantForAccessibility={eyebrow ? 'auto' : 'no-hide-descendants'}
Expand Down
Loading