Skip to content
Open
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ You should have a look at [the pitfalls and troubleshooting](./docs/pitfalls.md)

Read the [state of accessibility](./docs/accessibility.md).

# Right-to-left layouts

Read [right-to-left layouts](./docs/rtl.md) for Arabic, Hebrew, Farsi or Urdu apps.

# Contributing

## Publishing the package
Expand Down
1 change: 1 addition & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ It also ensures that the scroll event is propagated properly to parent ScrollVie
| `descendingArrow` | `ReactElement` | For web TVs cursor handling. Optional component to display as the arrow to scroll on the descending order. |
| `descendingArrowContainerStyle` | `ViewStyle` | For web TVs cursor handling. Style of the view which wraps the descending arrow. Hover this view will trigger the scroll. |
| `scrollInterval` | `number` | For web TVs cursor handling. Speed of the pointer scroll. It represents the interval in ms between every item scrolled. Default value is set to 100. |
| `rtl` | `boolean` | Lays a horizontal list out for a mirrored layout: the first item sits at the right edge and the list scrolls leftwards. Defaults to `I18nManager.isRTL` on native, and to `false` on the web where the direction comes from the DOM. Pass `false` for a horizontal list that must stay left-to-right inside a mirrored app (a time axis, playback controls). Ignored on a vertical list. See [right-to-left layouts](./rtl.md). |

The `SpatialNavigationVirtualizedList` component ref expose the following methods:

Expand Down
89 changes: 89 additions & 0 deletions docs/rtl.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Right-to-left layouts

A TV app translated into Arabic, Hebrew, Farsi or Urdu runs with a mirrored layout
(`I18nManager.isRTL === true`): Yoga lays every row out from the right, and React Native
rewrites `left` as `right` in the whole tree.

Two things have to follow that direction in a spatially navigated app: the **layout** of the
horizontal virtualized lists, which this library owns, and the **remote control mapping**, which
your app owns.

## What the library does

A horizontal `SpatialNavigationVirtualizedList` lays its items out from the right edge and scrolls
leftwards when the layout is mirrored. This is automatic on native, where the list reads
`I18nManager.isRTL`.

Nothing else in the library needs to know about the reading direction:

- **vertical lists** translate on the Y axis and are never mirrored;
- the **rows of a grid** are flex rows, so Yoga mirrors them on its own;
- the **default focus** stays on index 0, which a mirrored layout draws at the right edge, the
first item in reading order.

### On the web

`I18nManager` carries no direction on react-native-web (the layout direction comes from the DOM),
so a list defaults to a left-to-right layout there. Pass the `rtl` prop explicitly if your web app
renders in a right-to-left direction.

## What your app has to do: the remote control mapping

LRUD, the engine under the library, navigates by **logical index**, and never measures a layout:

```js
// @bam.tech/lrud
var offset = direction === Directions.LEFT || direction === Directions.UP ? -1 : 1;
```

`RIGHT` therefore means "next sibling in the tree", which a mirrored layout draws on the **left** of
the screen. Left as is, pressing right on the remote moves the focus to the left.

Swap the two horizontal directions in your `configureRemoteControl` mapping, which is the single
place where a key becomes a direction:

```jsx
const mapping = {
ArrowRight: I18nManager.isRTL ? Directions.LEFT : Directions.RIGHT,
ArrowLeft: I18nManager.isRTL ? Directions.RIGHT : Directions.LEFT,
ArrowUp: Directions.UP,
ArrowDown: Directions.DOWN,
};
```

`UP` and `DOWN` are never swapped: the mirror is horizontal.

## Lists that must stay left-to-right in a mirrored app

Some surfaces are not mirrored even in Arabic, and are usually pinned with `direction: 'ltr'` on an
ancestor view:

- **playback controls**, which follow the direction of the tape rather than the reading direction;
- a **time axis** (an EPG grid, a timeline), since time flows to the right in every culture.

React Native decides the `left`/`right` rewriting **once, at the root of the surface**, so inside
such a subtree the items of a list keep their `left: 0` anchor on the left. The library cannot see
the resolved direction of a subtree from JavaScript: tell it with `rtl={false}`, otherwise its
items are pushed off screen.

```jsx
<View style={{ direction: 'ltr' }}>
<SpatialNavigationVirtualizedList
data={dates}
renderItem={renderDate}
itemSize={120}
orientation="horizontal"
// This strip is a time axis: it is not mirrored, so neither is its layout.
rtl={false}
/>
</View>
```

The same prop takes a `true` if you mirror a subtree of an otherwise left-to-right app.

## Scope

The mirrored layout covers the horizontal virtualized lists, on the three scroll behaviours.
Everything else is either direction-agnostic (vertical lists, the rows of a grid, which Yoga
mirrors on its own) or up to your app: the remote control mapping above, and the subtrees you
choose to pin left-to-right.
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
import { RenderResult, act, render, screen } from '@testing-library/react-native';
import { I18nManager, Platform, StyleSheet, ViewStyle } from 'react-native';
import { ReactTestInstance } from 'react-test-renderer';
import { TestButton } from '../tests/TestButton';
import { SpatialNavigationRoot } from '../Root';
import '../tests/helpers/configureTestRemoteControl';
import { SpatialNavigationVirtualizedList } from './SpatialNavigationVirtualizedList';
import { ScrollBehavior } from './VirtualizedList';
import { DefaultFocus } from '../../context/DefaultFocusContext';
import testRemoteControlManager from '../tests/helpers/testRemoteControlManager';
import { setComponentLayoutSize } from '../../../testing/setComponentLayoutSize';
import { NodeOrientation } from '../../types/orientation';

/**
* A horizontal virtualized list on a mirrored layout.
*
* Every item is laid at `left: 0` plus a `translateX` of `index × size`, and
* the list scrolls by translating its container by `-offset`. React Native
* rewrites that `left: 0` anchor into `right: 0` on a mirrored tree, but the
* translations are geometric: without the two signs flipped, the items are
* pushed rightwards from the right anchor, off screen.
*/

const ITEM_SIZE = 100;
const LIST_TEST_ID = 'test-list';
const NUMBER_OF_ITEMS = 10;

const setLayoutDirection = (isRTL: boolean) =>
Object.defineProperty(I18nManager, 'isRTL', {
value: isRTL,
configurable: true,
writable: true,
});

/** Runs every test of the enclosing `describe` with the layout mirrored (or not). */
const mockLayoutDirection = (isRTL: boolean) => {
const originalDirection = I18nManager.isRTL;

beforeEach(() => setLayoutDirection(isRTL));
afterEach(() => setLayoutDirection(originalDirection));
};

const expectButtonToHaveFocus = (component: RenderResult, text: string) => {
const element = component.getByRole('button', { name: text });
expect(element).toBeSelected();
};

const expectListToHaveScroll = (listElement: ReactTestInstance, scrollValue: number) =>
expect(listElement).toHaveStyle({ transform: [{ translateX: scrollValue }] });

const expectVerticalListToHaveScroll = (listElement: ReactTestInstance, scrollValue: number) =>
expect(listElement).toHaveStyle({ transform: [{ translateY: scrollValue }] });

/**
* The `translateX` the list gave the container of the nth button: the closest
* ancestor of the button that the list translated, the item containers having
* no testID of their own.
*/
const getItemTranslateX = (index: number) => {
let node: ReactTestInstance | null = screen.getByText(`button ${index + 1}`);

while (node) {
const style = StyleSheet.flatten(node.props?.style) as ViewStyle | undefined;
const transform = style?.transform as Array<{ translateX?: number }> | undefined;
const translateX = transform?.find((entry) => 'translateX' in entry)?.translateX;
if (translateX !== undefined) return translateX;
node = node.parent;
}

return undefined;
};

describe('SpatialNavigationVirtualizedList on a mirrored layout', () => {
const data = Array.from({ length: NUMBER_OF_ITEMS }, () => ({ onSelect: () => undefined }));

const renderItem = ({ item, index }: { item: { onSelect: () => void }; index: number }) => (
<TestButton title={`button ${index + 1}`} onSelect={item.onSelect} />
);

const renderList = ({
rtl,
orientation = 'horizontal',
scrollBehavior,
}: {
rtl?: boolean;
orientation?: NodeOrientation;
scrollBehavior?: ScrollBehavior;
} = {}) => {
const component = render(
<SpatialNavigationRoot>
<DefaultFocus>
<SpatialNavigationVirtualizedList
testID={LIST_TEST_ID}
renderItem={renderItem}
data={data}
itemSize={ITEM_SIZE}
orientation={orientation}
scrollBehavior={scrollBehavior}
rtl={rtl}
/>
</DefaultFocus>
</SpatialNavigationRoot>,
);
act(() => jest.runAllTimers());
setComponentLayoutSize(LIST_TEST_ID, component, { width: 300, height: 300 });

return component;
};

describe('when the layout is mirrored', () => {
mockLayoutDirection(true);

it('stacks the items leftwards from the right anchor', async () => {
renderList();

expect(getItemTranslateX(0)).toBe(-0);
expect(getItemTranslateX(1)).toBe(-ITEM_SIZE);
expect(getItemTranslateX(2)).toBe(-2 * ITEM_SIZE);
});

it('slides the container rightwards when the focus moves to the next item', async () => {
const component = renderList();

const listElement = await component.findByTestId(LIST_TEST_ID);
expectListToHaveScroll(listElement, 0);

testRemoteControlManager.handleRight();
expectButtonToHaveFocus(component, 'button 2');
expectListToHaveScroll(listElement, ITEM_SIZE);

testRemoteControlManager.handleRight();
expectButtonToHaveFocus(component, 'button 3');
expectListToHaveScroll(listElement, 2 * ITEM_SIZE);
});

// The sign is flipped in the animation hook, the single point common to the
// three scroll behaviours, so each of them slides the other way.
it('slides the container rightwards with stick-to-end', async () => {
const component = renderList({ scrollBehavior: 'stick-to-end' });

const listElement = await component.findByTestId(LIST_TEST_ID);
testRemoteControlManager.handleRight();
testRemoteControlManager.handleRight();
expectButtonToHaveFocus(component, 'button 3');
// The first three items fit on screen, so nothing has scrolled yet.
expectListToHaveScroll(listElement, 0);

testRemoteControlManager.handleRight();
expectButtonToHaveFocus(component, 'button 4');
expectListToHaveScroll(listElement, ITEM_SIZE);
});

it('slides the container rightwards with jump-on-scroll', async () => {
const component = renderList({ scrollBehavior: 'jump-on-scroll' });

const listElement = await component.findByTestId(LIST_TEST_ID);
testRemoteControlManager.handleRight();
testRemoteControlManager.handleRight();
expectListToHaveScroll(listElement, 0);

// Jumping to the next page of three items.
testRemoteControlManager.handleRight();
expectButtonToHaveFocus(component, 'button 4');
expectListToHaveScroll(listElement, 3 * ITEM_SIZE);
});

it('flips the same sign on the web animation path', async () => {
// The web hook returns the offset as a plain style rather than an
// Animated value, and has to mirror it the same way.
const originalOS = Platform.OS;
Object.defineProperty(Platform, 'OS', { value: 'web', configurable: true, writable: true });

try {
const component = renderList();

const listElement = await component.findByTestId(LIST_TEST_ID);
testRemoteControlManager.handleRight();
expectButtonToHaveFocus(component, 'button 2');
expectListToHaveScroll(listElement, ITEM_SIZE);
} finally {
Object.defineProperty(Platform, 'OS', {
value: originalOS,
configurable: true,
writable: true,
});
}
});

it('renders and virtualizes the same items as on a left-to-right layout', async () => {
const component = renderList();

testRemoteControlManager.handleRight();
testRemoteControlManager.handleRight();
testRemoteControlManager.handleRight();
expectButtonToHaveFocus(component, 'button 4');

expect(screen.queryByText('button 1')).toBeFalsy();
expect(screen.getByText('button 2')).toBeTruthy();
expect(screen.getByText('button 8')).toBeTruthy();
expect(screen.queryByText('button 9')).toBeFalsy();
});

it('keeps the left-to-right layout of a list told rtl={false}', async () => {
// A list pinned with `direction: 'ltr'` (a time axis, playback controls)
// lives in a subtree where React Native keeps the `left: 0` anchor on
// the left. The library cannot see the resolved direction of a subtree,
// so the app has to tell it.
const component = renderList({ rtl: false });

expect(getItemTranslateX(1)).toBe(ITEM_SIZE);

const listElement = await component.findByTestId(LIST_TEST_ID);
testRemoteControlManager.handleRight();
expectListToHaveScroll(listElement, -ITEM_SIZE);
});

it('leaves a vertical list untouched', async () => {
const component = renderList({ orientation: 'vertical' });

expect(getItemTranslateX(1)).toBeUndefined();

const listElement = await component.findByTestId(LIST_TEST_ID);
testRemoteControlManager.handleDown();
expectButtonToHaveFocus(component, 'button 2');
expectVerticalListToHaveScroll(listElement, -ITEM_SIZE);
});
});

describe('when the layout is not mirrored', () => {
mockLayoutDirection(false);

it('stacks the items rightwards and slides the container leftwards', async () => {
const component = renderList();

expect(getItemTranslateX(1)).toBe(ITEM_SIZE);

const listElement = await component.findByTestId(LIST_TEST_ID);
testRemoteControlManager.handleRight();
expectButtonToHaveFocus(component, 'button 2');
expectListToHaveScroll(listElement, -ITEM_SIZE);
});

it('lays a list told rtl={true} out for a mirrored layout', async () => {
const component = renderList({ rtl: true });

expect(getItemTranslateX(1)).toBe(-ITEM_SIZE);

const listElement = await component.findByTestId(LIST_TEST_ID);
testRemoteControlManager.handleRight();
expectListToHaveScroll(listElement, ITEM_SIZE);
});
});
});
Loading