Skip to content

Commit

Permalink
Merge pull request #31 from bamlab/feat/accessibility
Browse files Browse the repository at this point in the history
Feat: add rudimentary accessibility support
  • Loading branch information
pierpo authored Nov 1, 2023
2 parents 4e77dc8 + 1dc5d43 commit 2961322
Show file tree
Hide file tree
Showing 13 changed files with 112 additions and 17 deletions.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
- [How to run the example](#how-to-run-the-example)
- [API documentation](#api-documentation)
- [Pitfalls](#pitfalls)
- [Accessibility support](#accessibility-support)
- [Contributing](#contributing)

# Why?

Expand Down Expand Up @@ -90,6 +92,10 @@ You can have a look at [the documentation](./docs/api.md).

You should have a look at [the pitfalls](./docs/pitfalls.md).

# Accessibility support

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

# Contributing

## Publishing the package
Expand Down
17 changes: 17 additions & 0 deletions docs/accessibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Accessibility

For now, accessibility support is experimental with the library.
Here's a video of what we could achieve.

![talkback](./talkback.gif)

Since we bypass the native focus, and the screen readers rely on the native elements, it's
a difficult topic.

We export a hook that returns you props that you can provide to your focusable elements.
The main caveat is that your elements will still be focusable, but the user will need to press
enter to grab focus on an element, which is not standard at all.

We could not find a way to properly intercept the accessibility focus event, even with a React Native patch.

Help is welcome 🙂
28 changes: 28 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,3 +343,31 @@ In this example, the arrow keys are used to navigate.
The 'keydown' event listener invokes the callback with a mapped `Direction` corresponding to the key that was pressed.
It returns an event identifier that can be used to remove the event listener later,
ensuring that the component will clean up after itself when it is no longer needed.


# useSpatialNavigatorFocusableAccessibilityProps

> Check out the [accessibility state of the lib](./accessibility.md) for more info and a little demo.
This is a custom React hook that is used to provide suggested accessibility properties for a focusable component.
It contains the following workaround (which is not standard at all, but the best we could achieve with TalkBack...):
- if I focus an element using accessibility focus, nothing happens (unfortunately)
- to get our custom focus, I need to press enter first
- once our custom focus is on, if I press enter again then my element is selected

## Usage

```tsx
const Button = () => {
// You can't use the hook directly in `FocusableButton` since it needs to access
// the current SpatialNavigationNode's context
const accessibilityProps = useSpatialNavigatorFocusableAccessibilityProps();

return <Button {...accessibilityProps}>My Button</Button>;
};

const FocusableButton = () => {
// Do not put the hook here!
return <SpatialNavigationNode isFocusable>{({isFocused}) => <Button isFocused={isFocused} />}</Button>
}
```
Binary file modified docs/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 1 addition & 2 deletions docs/pitfalls.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,4 @@ Once you do that, there is always an LRUD node living at the place of your butto

## Accessibility

The lib cannot support accessibility features yet as the focus system is not the native one.
Contributions would be appreciated!
As mentioned in [accessibility](./accessibility.md), support is experimental. And help is welcome 🙂
Binary file added docs/talkback.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 8 additions & 4 deletions packages/example/src/design-system/components/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { forwardRef } from 'react';
import { Animated, View } from 'react-native';
import { SpatialNavigationNode } from 'react-tv-space-navigation';
import {
SpatialNavigationNode,
useSpatialNavigatorFocusableAccessibilityProps,
} from 'react-tv-space-navigation';
import { Typography } from './Typography';
import styled from '@emotion/native';
import { useFocusAnimation } from '../helpers/useFocusAnimation';
Expand All @@ -14,18 +17,19 @@ type ButtonProps = {
const ButtonContent = forwardRef<View, { label: string; isFocused: boolean }>((props, ref) => {
const { isFocused, label } = props;
const anim = useFocusAnimation(isFocused);
const accessibilityProps = useSpatialNavigatorFocusableAccessibilityProps();
return (
<Container style={anim} isFocused={isFocused} ref={ref}>
<Container style={anim} isFocused={isFocused} ref={ref} {...accessibilityProps}>
<ColoredTypography isFocused={isFocused}>{label}</ColoredTypography>
</Container>
);
});

ButtonContent.displayName = 'ButtonContent';

export const Button = ({ label }: ButtonProps) => {
export const Button = ({ label, onSelect }: ButtonProps) => {
return (
<SpatialNavigationNode isFocusable>
<SpatialNavigationNode isFocusable onSelect={onSelect}>
{({ isFocused }) => <ButtonContent label={label} isFocused={isFocused} />}
</SpatialNavigationNode>
);
Expand Down
4 changes: 4 additions & 0 deletions packages/example/src/modules/program/view/Program.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React from 'react';
import { Animated, Image, View } from 'react-native';
import { ProgramInfo } from '../domain/programInfo';
import { useFocusAnimation } from '../../../design-system/helpers/useFocusAnimation';
import { useSpatialNavigatorFocusableAccessibilityProps } from 'react-tv-space-navigation';

type ProgramProps = {
isFocused?: boolean;
Expand All @@ -15,11 +16,14 @@ export const Program = React.forwardRef<View, ProgramProps>(

const scaleAnimation = useFocusAnimation(isFocused);

const accessibilityProps = useSpatialNavigatorFocusableAccessibilityProps();

return (
<ProgramContainer
style={scaleAnimation} // Apply the animated scale transform
ref={ref}
isFocused={isFocused}
{...accessibilityProps}
>
<ProgramImage source={imageSource} />
</ProgramContainer>
Expand Down
6 changes: 4 additions & 2 deletions packages/example/src/pages/ProgramDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ export const ProgramDetail = ({
{programInfo.description}
</Description>
<Spacer gap="$8" />
<Button label="Play" />
{/* eslint-disable-next-line no-console */}
<Button label="Play" onSelect={() => console.log('Playing!')} />
<Spacer gap="$8" />
<Button label="More info" />
{/* eslint-disable-next-line no-console */}
<Button label="More info" onSelect={() => console.log('More info!')} />
</Box>
</DefaultFocus>
</Container>
Expand Down
1 change: 1 addition & 0 deletions packages/lib/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export { SpatialNavigationView } from './spatial-navigation/components/View';
export { DefaultFocus } from './spatial-navigation/context/DefaultFocusContext';
export { SpatialNavigationVirtualizedList } from './spatial-navigation/components/virtualizedList/SpatialNavigationVirtualizedList';
export { SpatialNavigationVirtualizedGrid } from './spatial-navigation/components/virtualizedGrid/SpatialNavigationVirtualizedGrid';
export { useSpatialNavigatorFocusableAccessibilityProps } from './spatial-navigation/hooks/useSpatialNavigatorFocusableAccessibilityProps';

export const SpatialNavigation = {
configureRemoteControl,
Expand Down
4 changes: 4 additions & 0 deletions packages/lib/src/spatial-navigation/SpatialNavigator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export default class SpatialNavigator {
return this.lrud.assignFocus(id);
}

public getCurrentFocusNode() {
return this.lrud.currentFocusNode;
}

public lock() {
this.isLocked = true;
}
Expand Down
26 changes: 17 additions & 9 deletions packages/lib/src/spatial-navigation/components/Node.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ type FocusableProps = {
};
type NonFocusableProps = {
isFocusable?: false;
children: React.ReactNode;
children: React.ReactElement;
};
type DefaultProps = {
onFocus?: () => void;
Expand All @@ -26,19 +26,25 @@ type DefaultProps = {
};
type Props = DefaultProps & (FocusableProps | NonFocusableProps);

const useScrollIfNeeded = (): {
scrollToNodeIfNeeded: () => void;
bindRefToChild: (child: React.ReactElement) => React.ReactElement;
} => {
const innerReactNodeRef = useRef<View | null>(null);
const useScrollToNodeIfNeeded = ({
childRef,
}: {
childRef: React.MutableRefObject<View | null>;
}) => {
const { scrollToNodeIfNeeded } = useSpatialNavigatorParentScroll();

return () => scrollToNodeIfNeeded(childRef);
};

const useBindRefToChild = () => {
const childRef = useRef<View | null>(null);

const bindRefToChild = (child: React.ReactElement) => {
return React.cloneElement(child, {
...child.props,
ref: (node: View) => {
// We need the reference for our scroll handling
innerReactNodeRef.current = node;
childRef.current = node;

// @ts-expect-error @fixme This works at runtime but we couldn't find how to type it properly.
// Let's check if a ref was given (not by us)
Expand All @@ -54,7 +60,7 @@ const useScrollIfNeeded = (): {
});
};

return { scrollToNodeIfNeeded: () => scrollToNodeIfNeeded(innerReactNodeRef), bindRefToChild };
return { bindRefToChild, childRef };
};

export const SpatialNavigationNode = ({
Expand All @@ -71,7 +77,9 @@ export const SpatialNavigationNode = ({
// If parent changes, we have to re-register the Node + all children -> adding the parentId to the nodeId makes the children re-register.
const id = useUniqueId({ prefix: `${parentId}_node_` });

const { scrollToNodeIfNeeded, bindRefToChild } = useScrollIfNeeded();
const { childRef, bindRefToChild } = useBindRefToChild();

const scrollToNodeIfNeeded = useScrollToNodeIfNeeded({ childRef });

/*
* We don't re-register in LRUD on each render, because LRUD does not allow updating the nodes.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useParentId } from '../context/ParentIdContext';
import { useSpatialNavigator } from '../context/SpatialNavigatorContext';

export const useSpatialNavigatorFocusableAccessibilityProps = () => {
const spatialNavigator = useSpatialNavigator();
const id = useParentId();

return {
accessible: true,
accessibilityRole: 'button' as const,
accessibilityActions: [{ name: 'activate' }] as const,
onAccessibilityAction: () => {
const currentNode = spatialNavigator.getCurrentFocusNode();

if (currentNode?.id === id) {
spatialNavigator.getCurrentFocusNode()?.onSelect?.(currentNode);
} else {
spatialNavigator.grabFocus(id);
}
},
};
};

0 comments on commit 2961322

Please sign in to comment.