Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion apps/mobile/src/components/agents/chat-composer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const TEXT_DIRECTIONS = [
{
direction: 'RTL',
isRTL: true,
style: [{ writingDirection: 'rtl' }, { letterSpacing: 0 }, undefined],
style: [{ writingDirection: 'rtl' }, undefined, undefined],
},
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const TEXT_DIRECTIONS = [
{
direction: 'RTL',
isRTL: true,
style: [{ writingDirection: 'rtl' }, { letterSpacing: 0 }, undefined],
style: [{ writingDirection: 'rtl' }, undefined, undefined],
},
];

Expand Down
34 changes: 30 additions & 4 deletions apps/mobile/src/components/home/section-header.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,13 @@ describe('SectionHeader mounted layout', () => {
expect(label.props.maxFontSizeMultiplier).toBeUndefined();
expect(label.props.adjustsFontSizeToFit).not.toBe(true);
expect(label.children).toEqual(['Live now']);
// The tracked class stays for the LTR design; RTL renders it unspaced, so
// the Arabic labels keep their joins (see lib/rtl-text.ts).
// The tracked class stays for the Latin design; the RTL letter-spacing
// reset applies to Arabic-script copy only, so this Latin label keeps its
// tracking (see lib/rtl-text.ts and text.rtl-labels.mounted.test.tsx).
if (isRTL) {
expect(label.props.style).toContainEqual({ writingDirection: 'rtl' });
expect(label.props.style).toContainEqual({ letterSpacing: 0 });
expect(text.props.style).toContainEqual({ letterSpacing: 0 });
expect(label.props.style).not.toContainEqual({ letterSpacing: 0 });
expect(text.props.style).not.toContainEqual({ letterSpacing: 0 });
} else {
expect(label.props.style).toBeUndefined();
expect(text.props.style).toBeUndefined();
Expand All @@ -107,6 +108,31 @@ describe('SectionHeader mounted layout', () => {
expect(text.children).toEqual(['See all']);
});

it('renders Arabic labels without the mono family or letter spacing in RTL', () => {
i18nManager.isRTL = true;
const root = mount(
createElement(SectionHeader, {
label: 'الجلسات الجارية الآن',
actionLabel: 'عرض الكل',
onActionPress: () => undefined,
})
);
const label = root.find(
node => Object.is(node.type, 'Text') && node.children.includes('الجلسات الجارية الآن')
);
const action = root.findByProps({ accessibilityRole: 'button' });
const text = action.find(node => Object.is(node.type, 'Text'));

for (const node of [label, text]) {
const classes = (node.props.className as string).split(' ');
expect(classes.some(token => token.startsWith('font-mono'))).toBe(false);
expect(node.props.style).toContainEqual({ writingDirection: 'rtl' });
expect(node.props.style).toContainEqual({ letterSpacing: 0 });
}
expect(label.children).toEqual(['الجلسات الجارية الآن']);
expect(text.children).toEqual(['عرض الكل']);
});

it.each([{ isRTL: false }, { isRTL: true }])(
'aligns the action with the row edges, never with a physical text align, with RTL=$isRTL',
({ isRTL }) => {
Expand Down
29 changes: 24 additions & 5 deletions apps/mobile/src/components/ui/text.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,27 @@ afterEach(() => {

describe('Text eyebrow letterspacing', () => {
// Finding home-ar-loading: an Arabic section label carried the Latin
// uppercase letter-spacing and broke apart mid-word ('ال جلسا ت').
it.each([false, true])('keeps the eyebrow display treatment in LTR only (RTL=%s)', isRTL => {
// uppercase letter-spacing and broke apart mid-word ('ال جلسا ت'). The
// display treatment is dropped for RTL-script copy (Arabic, Hebrew) in an
// RTL interface.
it.each([false, true])('keeps the eyebrow display treatment for Latin copy (RTL=%s)', isRTL => {
i18nManager.isRTL = isRTL;
const classes = hostClasses(mount(createElement(Text, { variant: 'eyebrow' }, 'Live now')));
expect(classes).toEqual(
expect.arrayContaining(['font-mono-medium', 'text-[10px]', 'text-muted-foreground'])
expect.arrayContaining([
'font-mono-medium',
'text-[10px]',
'text-muted-foreground',
'uppercase',
'tracking-[1.5px]',
])
);
});

it.each([false, true])('drops the treatment from Arabic copy in RTL (RTL=%s)', isRTL => {
i18nManager.isRTL = isRTL;
const classes = hostClasses(
mount(createElement(Text, { variant: 'eyebrow' }, 'الجلسات الجارية الآن'))
);
if (isRTL) {
expect(classes).not.toContain('uppercase');
Expand All @@ -69,12 +84,16 @@ describe('Text eyebrow letterspacing', () => {
const classes = hostClasses(
mount(createElement(Eyebrow, null, isRTL ? 'الجلسات الجارية الآن' : 'LIVE NOW'))
);
expect(classes).toEqual(expect.arrayContaining(['font-mono-medium', 'text-[10px]']));
expect(classes).toEqual(expect.arrayContaining(['text-[10px]']));
if (isRTL) {
// Arabic copy in an RTL interface also drops the mono family.
expect(classes.some(name => name.startsWith('font-mono'))).toBe(false);
expect(classes).not.toContain('uppercase');
expect(classes.some(name => name.startsWith('tracking'))).toBe(false);
} else {
expect(classes).toEqual(expect.arrayContaining(['uppercase', 'tracking-[1.5px]']));
expect(classes).toEqual(
expect.arrayContaining(['font-mono-medium', 'uppercase', 'tracking-[1.5px]'])
);
}
});
});
112 changes: 112 additions & 0 deletions apps/mobile/src/components/ui/text.rtl-labels.mounted.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { createElement, type ReactElement } from 'react';
import { act, TestRenderer } from '@/test/renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { Eyebrow } from '@/components/ui/eyebrow';
import { Text } from '@/components/ui/text';

const i18nManager = vi.hoisted(() => ({ isRTL: false }));
vi.mock('react-native', () => ({
I18nManager: i18nManager,
Text: 'Text',
}));
vi.mock('@rn-primitives/slot', () => ({ Text: 'Slot.Text' }));

let renderer: TestRenderer.ReactTestRenderer | undefined = undefined;
function mount(element: ReactElement) {
act(() => {
renderer = TestRenderer.create(element);
});
if (!renderer) {
throw new Error('Missing text renderer');
}
return renderer.root;
}

function hostText(root: TestRenderer.ReactTestInstance) {
return root.find(node => Object.is(node.type, 'Text'));
}

const ARABIC = 'الجلسات الجارية الآن';
// U+0870–U+089F, Arabic Extended-B: Arabic-script characters outside the
// blocks the first fix matched.
const ARABIC_EXTENDED_B = '\u0870\u089F';
// The Hebrew eyebrow copy from `he.json` (`home.agentSessions`): Hebrew is an
// RTL locale the app ships and is not Arabic script, so an Arabic-only
// predicate leaves it with the Latin tracking and mono family.
const HEBREW = 'פעילים עכשיו';
const LATIN = 'Live now';

beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
i18nManager.isRTL = false;
});
afterEach(() => {
act(() => renderer?.unmount());
renderer = undefined;
});

describe('Text eyebrow in an RTL interface', () => {
it('drops the mono family and letter spacing from an Arabic label', () => {
i18nManager.isRTL = true;
const label = hostText(mount(createElement(Text, { variant: 'eyebrow' }, ARABIC)));
const classes = (label.props.className as string).split(' ');

expect(classes.some(token => token.startsWith('font-mono'))).toBe(false);
expect(classes).toEqual(expect.arrayContaining(['text-[10px]', 'text-muted-foreground']));
expect(label.props.style).toContainEqual({ letterSpacing: 0 });
expect(label.props.style).toContainEqual({ writingDirection: 'rtl' });
expect(label.children).toEqual([ARABIC]);
});

it('drops the mono family and letter spacing from an Extended-B-only label', () => {
i18nManager.isRTL = true;
const label = hostText(mount(createElement(Text, { variant: 'eyebrow' }, ARABIC_EXTENDED_B)));
const classes = (label.props.className as string).split(' ');

expect(classes.some(token => token.startsWith('font-mono'))).toBe(false);
expect(label.props.style).toContainEqual({ letterSpacing: 0 });
expect(label.children).toEqual([ARABIC_EXTENDED_B]);
});

it('drops the mono family and letter spacing from a Hebrew label', () => {
i18nManager.isRTL = true;
const label = hostText(mount(createElement(Text, { variant: 'eyebrow' }, HEBREW)));
const classes = (label.props.className as string).split(' ');

expect(classes.some(token => token.startsWith('font-mono'))).toBe(false);
expect(classes).not.toContain('tracking-[1.5px]');
expect(label.props.style).toContainEqual({ letterSpacing: 0 });
expect(label.props.style).toContainEqual({ writingDirection: 'rtl' });
expect(label.children).toEqual([HEBREW]);
});

it('keeps the tracked mono design for a Latin label', () => {
i18nManager.isRTL = true;
const label = hostText(mount(createElement(Text, { variant: 'eyebrow' }, LATIN)));
const classes = (label.props.className as string).split(' ');

expect(classes).toContain('font-mono-medium');
expect(classes).toContain('tracking-[1.5px]');
expect(label.props.style).toEqual([{ writingDirection: 'rtl' }, undefined, undefined]);
});

it('keeps the mono family and adds no letter spacing for Arabic in an LTR interface', () => {
i18nManager.isRTL = false;
const label = hostText(mount(createElement(Text, { variant: 'eyebrow' }, ARABIC)));
const classes = (label.props.className as string).split(' ');

expect(classes).toContain('font-mono-medium');
expect(label.props.style).toBeUndefined();
});

it('applies the same rule to the Eyebrow wrapper', () => {
i18nManager.isRTL = true;
const label = hostText(mount(createElement(Eyebrow, null, ARABIC)));
const classes = (label.props.className as string).split(' ');

expect(classes.some(token => token.startsWith('font-mono'))).toBe(false);
expect(label.props.style).toContainEqual({ letterSpacing: 0 });
expect(label.children).toEqual([ARABIC]);
});
});
29 changes: 28 additions & 1 deletion apps/mobile/src/components/ui/text.rtl-tracking.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ afterEach(() => {
// and the bottom tab labels.
const TRACKED_CLASSES = ['tracking-[1.5px]', 'tracking-[0.2px]'] as const;

// The Hebrew eyebrow copy from `he.json` (`home.agentSessions`). Hebrew is a
// shipped RTL locale and not Arabic script, so the reset has to reach it too.
const HEBREW = 'פעילים עכשיו';

describe('Text tracked labels in RTL', () => {
it.each(TRACKED_CLASSES)(
'draws %s with no letter spacing while a tracked class stays on the element',
Expand All @@ -62,6 +66,24 @@ describe('Text tracked labels in RTL', () => {
}
);

it('resets a caller-tracked Hebrew label, not only Arabic', () => {
i18nManager.isRTL = true;
const root = mount(createElement(Text, { className: 'tracking-[0.2px]' }, HEBREW));

expect(hostText(root).props.className as string).toContain('tracking-[0.2px]');
expect(hostStyle(root)).toContainEqual(RTL_NO_LETTER_SPACING);
});

it('drops the eyebrow Latin display treatment from Hebrew copy', () => {
i18nManager.isRTL = true;
const root = mount(createElement(Text, { variant: 'eyebrow' }, HEBREW));

const className = hostText(root).props.className as string;
expect(className.split(' ')).not.toContain('uppercase');
expect(className).not.toContain('tracking');
expect(hostStyle(root)).toContainEqual(RTL_NO_LETTER_SPACING);
});

it('leaves no non-zero letter spacing on a tracked label in any class order', () => {
i18nManager.isRTL = true;
const root = mount(
Expand All @@ -82,8 +104,10 @@ describe('Text tracked labels in RTL', () => {
it('keeps the caller style after the RTL defaults', () => {
i18nManager.isRTL = true;
const callerStyle = { color: '#ff0000' };
// RTL-script copy, the copy the merged rule resets (text.rtl-labels:
// Latin labels keep their tracking); the caller style still lands last.
const root = mount(
createElement(Text, { className: 'tracking-[1.5px]', style: callerStyle }, '…')
createElement(Text, { className: 'tracking-[1.5px]', style: callerStyle }, 'استكشف')
);

expect(hostStyle(root)).toContainEqual(callerStyle);
Expand All @@ -102,6 +126,9 @@ describe('Text tracked labels in RTL', () => {
i18nManager.isRTL = true;
const root = mount(createElement(Eyebrow, null, 'استكشف'));

// Arabic-script copy drops the tracked class and the mono family in an RTL
// interface (`withoutMonoFamily`): a zero letter spacing alone does not
// keep a cursive script's joins (text.rtl-labels, text.mounted).
// The eyebrow's Latin display treatment (uppercase + tracking) is LTR-only
// (see `Text`'s eyebrow variant and `SectionHeader`): the variant owns its
// display classes, so an RTL eyebrow drops them — it carries no tracked
Expand Down
36 changes: 23 additions & 13 deletions apps/mobile/src/components/ui/text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { I18nManager, Text as RNText, type Role } from 'react-native';

import { RTL_NO_LETTER_SPACING, RTL_WRITING_DIRECTION } from '@/lib/rtl-text';
import {
hasRtlScript,
RTL_NO_LETTER_SPACING,
RTL_WRITING_DIRECTION,
withoutMonoFamily,
} from '@/lib/rtl-text';
import { cn } from '@/lib/utils';

const textVariants = cva('text-foreground text-base font-medium', {
Expand Down Expand Up @@ -49,10 +54,12 @@ const ARIA_LEVEL = {
} satisfies Partial<Record<TextVariant, string>>;

/**
* The eyebrow's Latin display treatment: full capitals, letterspaced. It is an
* LTR-only addition to the variant because `letter-spacing` pulls a cursive
* script apart — an Arabic eyebrow renders 'الجلسات' as 'ال جلسا ت'. An RTL
* interface keeps the mono family, size and color and drops both classes.
* The eyebrow's Latin display treatment: full capitals, letterspaced. It is
* dropped for RTL-script copy in an RTL interface (`hasRtlScript`: the app
* ships Arabic-script languages and Hebrew): `letter-spacing` pulls a
* cursive script apart — an Arabic eyebrow renders 'الجلسات' as 'ال جلسا ت'
* — and that copy also drops the mono family (see `withoutMonoFamily`).
* Latin copy, and RTL-script copy in an LTR interface, keep the treatment.
*
* Exported so the eyebrow-scale labels rendered outside the variant — the
* `SectionHeader` action link — carry the identical treatment instead of a
Expand All @@ -74,20 +81,23 @@ function Text({
}) {
const textClass = React.useContext(TextClassContext);
const Component = asChild ? Slot.Text : RNText;
const isRTL = I18nManager.isRTL;
const isRtlScript = hasRtlScript(props.children);
const classes = cn(
textVariants({ variant }),
variant === 'eyebrow' && !(isRTL && isRtlScript) && EYEBROW_LATIN_DISPLAY,
textClass,
className
);
return (
<Component
className={cn(
textVariants({ variant }),
variant === 'eyebrow' && !I18nManager.isRTL && EYEBROW_LATIN_DISPLAY,
textClass,
className
)}
className={isRTL && isRtlScript ? withoutMonoFamily(classes) : classes}
role={variant ? ROLE[variant as keyof typeof ROLE] : undefined}
aria-level={variant ? ARIA_LEVEL[variant as keyof typeof ARIA_LEVEL] : undefined}
{...props}
style={
I18nManager.isRTL
? [RTL_WRITING_DIRECTION, RTL_NO_LETTER_SPACING, props.style]
isRTL
? [RTL_WRITING_DIRECTION, isRtlScript ? RTL_NO_LETTER_SPACING : undefined, props.style]
: props.style
}
/>
Expand Down
Loading
Loading