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
22 changes: 22 additions & 0 deletions apps/mobile/src/components/home/section-header.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,28 @@ describe('SectionHeader mounted layout', () => {
expect(text.children).toEqual(['See all']);
});

// The inline override only has a class to beat in LTR: there the eyebrow
// variant and the action keep `tracking-[1.5px]`, and the joined-script
// children still get `letterSpacing: 0` on top of it.
it('clears the tracked letter-spacing on the Arabic label and action', () => {
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 actionText = action.find(node => Object.is(node.type, 'Text'));

expect((label.props.className as string).split(' ')).toContain('tracking-[1.5px]');
expect(label.props.style).toContainEqual({ letterSpacing: 0 });
expect(actionText.props.style).toContainEqual({ letterSpacing: 0 });
});

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
55 changes: 55 additions & 0 deletions apps/mobile/src/components/ui/text.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ function mount(element: ReactElement) {
return renderer.root;
}

// Both assertions are kept: `hostText` reaches the host node to read its inline
// style, `hostClasses` reads the resolved class list.
function hostText(root: TestRenderer.ReactTestInstance) {
return root.find(node => Object.is(node.type, 'Text'));
}

function hostClasses(root: TestRenderer.ReactTestInstance): string[] {
const node = root.find(candidate => Object.is(candidate.type, 'Text'));
return String(node.props.className).split(' ');
Expand All @@ -40,6 +46,55 @@ afterEach(() => {
renderer = undefined;
});

describe('Text mounted letter spacing', () => {
it.each([false, true])('clears the tracking for Arabic children with isRTL=%s', isRTL => {
i18nManager.isRTL = isRTL;
const text = hostText(
mount(createElement(Text, { className: 'tracking-[1.5px]' }, 'الجلسات الجارية الآن'))
);

expect(text.props.style).toContainEqual({ letterSpacing: 0 });
});

it('leaves Latin children untouched and keeps LTR style undefined', () => {
const text = hostText(
mount(createElement(Text, { className: 'tracking-[1.5px]' }, 'Live now'))
);

expect(text.props.style).toBeUndefined();
});

it('clears the tracking of the eyebrow variant for Arabic children', () => {
const text = hostText(mount(createElement(Text, { variant: 'eyebrow' }, 'عرض الكل')));

expect(text.props.className as string).toContain('tracking-[1.5px]');
expect(text.props.style).toContainEqual({ letterSpacing: 0 });
});

it('clears the tab label tracking for Arabic children', () => {
const text = hostText(
mount(createElement(Text, { className: 'tracking-[0.2px]' }, 'الرئيسية'))
);

expect(text.props.className as string).toContain('tracking-[0.2px]');
expect(text.props.style).toContainEqual({ letterSpacing: 0 });
});

// The interface direction owns the reset outside joined script: an RTL
// interface draws every run unspaced (`RTL_NO_LETTER_SPACING`), so a Latin
// label keeps the paragraph direction and loses the tracking its class asks
// for. The joined-script rule only adds the override in an LTR interface.
it('keeps the RTL paragraph direction and the reset for Latin children', () => {
i18nManager.isRTL = true;
const text = hostText(
mount(createElement(Text, { className: 'tracking-[1.5px]' }, 'Live now'))
);

expect(text.props.style).toContainEqual({ writingDirection: 'rtl' });
expect(text.props.style).toContainEqual({ letterSpacing: 0 });
});
});

describe('Text eyebrow letterspacing', () => {
// Finding home-ar-loading: an Arabic section label carried the Latin
// uppercase letter-spacing and broke apart mid-word ('ال جلسا ت').
Expand Down
20 changes: 14 additions & 6 deletions apps/mobile/src/components/ui/text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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 { RTL_NO_LETTER_SPACING, RTL_WRITING_DIRECTION, textLetterSpacing } from '@/lib/rtl-text';
import { cn } from '@/lib/utils';

const textVariants = cva('text-foreground text-base font-medium', {
Expand Down Expand Up @@ -74,6 +74,18 @@ function Text({
}) {
const textClass = React.useContext(TextClassContext);
const Component = asChild ? Slot.Text : RNText;
// NativeWind merges the `className` style first and this inline `style` last,
// so an inline `letterSpacing: 0` overrides a class's `tracking-*` at every
// call site. Two rules fill the array: an RTL interface resets the tracking
// for every run, with the paragraph direction ahead of the reset as
// `RTL_NO_LETTER_SPACING` documents, and outside RTL a joined-script child
// still gets the reset so an Arabic label in an LTR screen keeps its joins
// (`textLetterSpacing`). A Latin run in LTR keeps the tracking its class asks
// for.
const ownStyles = [
I18nManager.isRTL ? RTL_WRITING_DIRECTION : undefined,
textLetterSpacing(props.children) ?? (I18nManager.isRTL ? RTL_NO_LETTER_SPACING : undefined),
].filter(Boolean);
return (
<Component
className={cn(
Expand All @@ -85,11 +97,7 @@ function Text({
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]
: props.style
}
style={ownStyles.length > 0 ? [...ownStyles, props.style] : props.style}
/>
);
}
Expand Down
79 changes: 79 additions & 0 deletions apps/mobile/src/lib/rtl-text.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { createElement } from 'react';
import { describe, expect, it, vi } from 'vitest';

import {
containsJoinedScript,
JOINED_SCRIPT,
NATURAL_LETTER_SPACING,
textLetterSpacing,
} from './rtl-text';

// `rtl-text` imports `I18nManager` for its direction helpers; the real module
// is Flow-syntax source this node project cannot load.
vi.mock('react-native', () => ({ I18nManager: { isRTL: false } }));

describe('JOINED_SCRIPT', () => {
it.each(['\u0600', '\u0750', '\u08A0', '\uFB50', '\uFE70'])(
'covers the block that starts at %j',
value => {
expect(JOINED_SCRIPT.test(value)).toBe(true);
}
);
});

describe('containsJoinedScript', () => {
it.each(['الجلسات الجارية الآن', 'الرئيسية', 'الوكلاء', 'الملف الشخصي', 'عرض الكل'])(
'detects Arabic in %j',
value => {
expect(containsJoinedScript(value)).toBe(true);
}
);

it.each([
['Arabic base', '\u0600'],
['Arabic Supplement', '\u0750'],
['Arabic Extended-A', '\u08A0'],
['Arabic Presentation Forms-A', '\uFB50'],
['Arabic Presentation Forms-B', '\uFE70'],
])('detects a glyph from %s', (_name, value) => {
expect(containsJoinedScript(value)).toBe(true);
});

it('detects Arabic inside a mixed array of strings', () => {
expect(containsJoinedScript(['عرض', ' ', 'الكل'])).toBe(true);
expect(containsJoinedScript(['Live now', 'عرض الكل'])).toBe(true);
});

it('is false for Latin, Hebrew, digits and punctuation', () => {
expect(containsJoinedScript('Live now')).toBe(false);
expect(containsJoinedScript('SEE ALL')).toBe(false);
expect(containsJoinedScript('שלום')).toBe(false);
expect(containsJoinedScript('1234 56.7%!?')).toBe(false);
expect(containsJoinedScript(['Live now', '1234'])).toBe(false);
});

it('is false for empty children', () => {
expect(containsJoinedScript(undefined)).toBe(false);
expect(containsJoinedScript(null)).toBe(false);
expect(containsJoinedScript(false)).toBe(false);
expect(containsJoinedScript([])).toBe(false);
expect(containsJoinedScript('')).toBe(false);
});

it('is false for number and element children', () => {
expect(containsJoinedScript(4)).toBe(false);
expect(containsJoinedScript(createElement('Text', null, 'الرئيسية'))).toBe(false);
});
});

describe('textLetterSpacing', () => {
it('returns the natural spacing for a joined script', () => {
expect(textLetterSpacing('الرئيسية')).toBe(NATURAL_LETTER_SPACING);
expect(textLetterSpacing(['عرض', ' الكل'])).toBe(NATURAL_LETTER_SPACING);
});

it('returns undefined without a joined script', () => {
expect(textLetterSpacing('Live now')).toBeUndefined();
expect(textLetterSpacing(4)).toBeUndefined();
});
});
46 changes: 46 additions & 0 deletions apps/mobile/src/lib/rtl-text.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { type ReactNode } from 'react';
import { I18nManager, type StyleProp, type TextStyle } from 'react-native';

/**
Expand Down Expand Up @@ -76,3 +77,48 @@ export function withRtlWritingDirection(style: TextStyle | undefined): TextStyle
* view's children too (it would move the diff gutter to the left).
*/
export const LTR_TEXT_DIRECTION: TextStyle = { direction: 'ltr', writingDirection: 'ltr' };

/**
* A joined script's letters connect into one shape, so `letter-spacing` — a
* Latin display device — opens every glyph from its neighbour and splits a word
* mid-shape («الوكلاء» draws as «الوكلا ء»). This is the script, not the
* interface direction: a Latin run inside an Arabic interface (`KiloClaw`,
* `PR`) joins nothing and keeps its tracking. Arabic, Arabic Supplement, Arabic
* Extended-A and the two Arabic Presentation Forms blocks are the ranges a
* joined Arabic run arrives in.
*/
export const JOINED_SCRIPT = /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/;

/** No added advance between glyphs, what a joined script's shaping expects. */
export const NATURAL_LETTER_SPACING: TextStyle = { letterSpacing: 0 };

/** A `ReactNode` string member, the only child a `Text` lays out as one run. */
function isStringChild(child: ReactNode): child is string {
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- ReactNode is a closed union; typeof is its string discriminant.
return typeof child === 'string';
}

/** A `ReactNode` array member; `Array.isArray` alone narrows it to `any[]`. */
function isChildArray(children: ReactNode): children is ReactNode[] {
return Array.isArray(children);
}

/**
* Whether a direct string child holds a glyph of a joined script. Array
* children recurse; a nested `Text` is its own run and applies the rule itself,
* and numbers, functions and elements are not strings.
*/
export function containsJoinedScript(children: ReactNode): boolean {
if (isStringChild(children)) {
return JOINED_SCRIPT.test(children);
}
if (isChildArray(children)) {
return children.some(child => containsJoinedScript(child));
}
return false;
}

/** The natural spacing for a joined script, or nothing to override. */
export function textLetterSpacing(children: ReactNode): TextStyle | undefined {
return containsJoinedScript(children) ? NATURAL_LETTER_SPACING : undefined;
}
Loading