diff --git a/apps/mobile/src/components/home/section-header.mounted.test.tsx b/apps/mobile/src/components/home/section-header.mounted.test.tsx index 63e2af6e55..c0d15c5eb7 100644 --- a/apps/mobile/src/components/home/section-header.mounted.test.tsx +++ b/apps/mobile/src/components/home/section-header.mounted.test.tsx @@ -36,65 +36,91 @@ afterEach(() => { describe('SectionHeader mounted layout', () => { // Host props protect the layout contract; only native I4 can prove scaled glyph rendering. - it.each([ - { isRTL: false, alignment: 'text-right' }, - { isRTL: true, alignment: 'text-left' }, - ])('gives both labels spare width and wrapping with RTL=$isRTL', ({ isRTL, alignment }) => { - i18nManager.isRTL = isRTL; - const root = mount( - createElement(SectionHeader, { - label: 'Live now', - actionLabel: 'See all', - onActionPress: () => undefined, - }) - ); - const action = root.findByProps({ accessibilityRole: 'button' }); - const text = action.find(node => Object.is(node.type, 'Text')); - const label = root.find( - node => Object.is(node.type, 'Text') && node.children.includes('Live now') - ); + it.each([{ isRTL: false }, { isRTL: true }])( + 'gives both labels spare width and wrapping with RTL=$isRTL', + ({ isRTL }) => { + i18nManager.isRTL = isRTL; + const root = mount( + createElement(SectionHeader, { + label: 'Live now', + actionLabel: 'See all', + onActionPress: () => undefined, + }) + ); + const action = root.findByProps({ accessibilityRole: 'button' }); + const text = action.find(node => Object.is(node.type, 'Text')); + const label = root.find( + node => Object.is(node.type, 'Text') && node.children.includes('Live now') + ); + + expect((label.props.className as string).split(' ')).toEqual( + expect.arrayContaining([ + 'grow', + 'max-w-full', + 'font-mono-medium', + 'text-[10px]', + 'tracking-[1.5px]', + 'uppercase', + 'text-muted-foreground', + ]) + ); + expect(label.props.numberOfLines).toBeUndefined(); + expect(label.props.allowFontScaling).not.toBe(false); + expect(label.props.maxFontSizeMultiplier).toBeUndefined(); + expect(label.props.adjustsFontSizeToFit).not.toBe(true); + expect(label.children).toEqual(['Live now']); + if (isRTL) { + expect(label.props.style).toContainEqual({ writingDirection: 'rtl' }); + } - expect((label.props.className as string).split(' ')).toEqual( - expect.arrayContaining([ - 'grow', - 'max-w-full', - 'font-mono-medium', - 'text-[10px]', - 'tracking-[1.5px]', - 'uppercase', - 'text-muted-foreground', - ]) - ); - expect(label.props.numberOfLines).toBeUndefined(); - expect(label.props.allowFontScaling).not.toBe(false); - expect(label.props.maxFontSizeMultiplier).toBeUndefined(); - expect(label.props.adjustsFontSizeToFit).not.toBe(true); - expect(label.children).toEqual(['Live now']); - if (isRTL) { - expect(label.props.style).toContainEqual({ writingDirection: 'rtl' }); + expect((action.parent?.props.className as string | undefined)?.split(' ')).toContain( + 'flex-wrap' + ); + // The action copy must sit at the row's end in both directions, so the + // box is a row that places its content at the main-axis end. The layout + // is direction-relative and identical under RTL. + expect((action.props.className as string).split(' ')).toEqual( + expect.arrayContaining(['grow', 'max-w-full', 'flex-row', 'justify-end']) + ); + expect((text.props.className as string).split(' ')).toEqual( + expect.arrayContaining([ + 'font-mono-medium', + 'text-[11px]', + 'tracking-[1.5px]', + 'uppercase', + 'text-primary', + ]) + ); + expect(text.props.numberOfLines).toBeUndefined(); + expect(text.props.allowFontScaling).not.toBe(false); + expect(text.props.maxFontSizeMultiplier).toBeUndefined(); + expect(text.children).toEqual(['See all']); } + ); - expect((action.parent?.props.className as string | undefined)?.split(' ')).toContain( - 'flex-wrap' - ); - expect((action.props.className as string).split(' ')).toEqual( - expect.arrayContaining(['grow', 'max-w-full']) - ); - expect((text.props.className as string).split(' ')).toEqual( - expect.arrayContaining([ - alignment, - 'font-mono-medium', - 'text-[11px]', - 'tracking-[1.5px]', - 'uppercase', - 'text-primary', - ]) - ); - expect(text.props.numberOfLines).toBeUndefined(); - expect(text.props.allowFontScaling).not.toBe(false); - expect(text.props.maxFontSizeMultiplier).toBeUndefined(); - expect(text.children).toEqual(['See all']); - }); + it.each([{ isRTL: false }, { isRTL: true }])( + 'aligns the action with the row edges, never with a physical text align, with RTL=$isRTL', + ({ isRTL }) => { + // React Native swaps `textAlign: 'left'` and `'right'` under RTL (Android + // maps 'left' to Gravity.RIGHT), so a physical alignment would pin the + // action to the inner edge of its box and float "See all" away from the + // row end in Arabic. + i18nManager.isRTL = isRTL; + const root = mount( + createElement(SectionHeader, { + label: 'Live now', + actionLabel: 'See all', + onActionPress: () => undefined, + }) + ); + const classes = root + .findAll(node => typeof node.props.className === 'string') + .flatMap(node => (node.props.className as string).split(' ')); + + expect(classes).not.toContain('text-left'); + expect(classes).not.toContain('text-right'); + } + ); it('keeps the complete accessible action name and activates the supplied destination', () => { function Destination() { diff --git a/apps/mobile/src/components/home/section-header.tsx b/apps/mobile/src/components/home/section-header.tsx index 3b74ee4b15..91b0a86559 100644 --- a/apps/mobile/src/components/home/section-header.tsx +++ b/apps/mobile/src/components/home/section-header.tsx @@ -1,11 +1,10 @@ -import { I18nManager, Pressable, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { Text } from '@/components/ui/text'; -import { cn } from '@/lib/utils'; type SectionHeaderProps = { label: string; - /** Optional right-aligned link (e.g. "SEE ALL"). */ + /** Optional link at the end of the row (e.g. "SEE ALL"). */ actionLabel?: string; onActionPress?: () => void; }; @@ -22,14 +21,15 @@ export function SectionHeader({ label, actionLabel, onActionPress }: Readonly - + {actionLabel}