Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: add rudimentary accessibility support #31

Merged
merged 6 commits into from
Nov 1, 2023
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
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);
}
},
};
};