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
49 changes: 49 additions & 0 deletions package/src/components/Accessibility/OverlayA11yShield.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import React, { PropsWithChildren } from 'react';
import { Platform, StyleSheet, View } from 'react-native';

import { useOverlayContext } from '../../contexts/overlayContext/OverlayContext';
import { useStateStore } from '../../hooks';
import { overlayStore } from '../../state-store/message-overlay-store';

const messageOverlayActiveSelector = (state: { id: string | undefined }) => ({
isMessageOverlayActive: state.id !== undefined,
});

/**
* Android only accessibility focus trap for the OverlayProvider's children
* tree. iOS handles modal focus traps natively via `accessibilityViewIsModal`
* on each overlay's root, but Android has no equivalent prop - the only
* JS side mechanism is to mark siblings as `'no-hide-descendants'`.
*
* The shield wraps `{children}` in a single View whose `importantForAccessibility`
* flips to `'no-hide-descendants'` whenever any focus trapping overlay is
* active (the full screen image/video gallery or the message context menu).
* When closed, the wrapper is a transparent passthrough.
*
* In terms of rerendering, only the wrapper View commits new props on overlay state
* transitions. The `{children}` element reference is stable across renders,
* so React reconciliation does not rerender any component below the wrapper.
*
* On iOS the wrapper is skipped entirely.
*/
export function OverlayA11yShield({ children }: PropsWithChildren) {
const { overlay } = useOverlayContext();
const { isMessageOverlayActive } = useStateStore(overlayStore, messageOverlayActiveSelector);

if (Platform.OS !== 'android') {
return <>{children}</>;
}

const isAnyOverlayActive = overlay === 'gallery' || isMessageOverlayActive;

return (
<View
accessibilityElementsHidden={isAnyOverlayActive}
importantForAccessibility={isAnyOverlayActive ? 'no-hide-descendants' : 'auto'}
style={StyleSheet.absoluteFill}
testID='overlay-a11y-shield'
>
{children}
</View>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import React from 'react';
import { Platform, Text } from 'react-native';

import { act, render, screen } from '@testing-library/react-native';

import { OverlayContext } from '../../../contexts/overlayContext/OverlayContext';
import { overlayStore } from '../../../state-store/message-overlay-store';
import { OverlayA11yShield } from '../OverlayA11yShield';

const setPlatform = (os: typeof Platform.OS) => {
Object.defineProperty(Platform, 'OS', { configurable: true, get: () => os });
};

const renderShield = (overlay: 'none' | 'gallery' = 'none') =>
render(
<OverlayContext.Provider
value={
{
overlay,
setOverlay: () => undefined,
style: undefined,
} as never
}
>
<OverlayA11yShield>
<Text testID='child'>child</Text>
</OverlayA11yShield>
</OverlayContext.Provider>,
);

// The wrapper sets `accessibilityElementsHidden` / `importantForAccessibility`
// when an overlay is active — RTL v13 filters those elements out of a11y queries
// by default. `includeHiddenElements: true` keeps them queryable for assertion.
const wrapper = () => screen.queryByTestId('overlay-a11y-shield', { includeHiddenElements: true });

describe('OverlayA11yShield', () => {
const originalOS = Platform.OS;
afterAll(() => setPlatform(originalOS));

beforeEach(() => {
act(() => {
overlayStore.partialNext({ closing: false, id: undefined, messageId: undefined });
});
});

describe('on Android', () => {
beforeAll(() => setPlatform('android'));

it('renders children inside the wrapper', () => {
renderShield();
expect(screen.getByTestId('child')).toBeTruthy();
expect(wrapper()).toBeTruthy();
});

it('does not hide descendants when no overlay is active', () => {
renderShield('none');
expect(wrapper()?.props.importantForAccessibility).toBe('auto');
});

it('hides descendants when the gallery overlay is active', () => {
renderShield('gallery');
expect(wrapper()?.props.importantForAccessibility).toBe('no-hide-descendants');
});

it('hides descendants when the message overlay opens', () => {
renderShield('none');
act(() => {
overlayStore.partialNext({ id: 'msg-1' });
});
expect(wrapper()?.props.importantForAccessibility).toBe('no-hide-descendants');
});
});

describe('on iOS', () => {
beforeAll(() => setPlatform('ios'));

it('renders children without a wrapper', () => {
renderShield();
expect(screen.getByTestId('child')).toBeTruthy();
expect(wrapper()).toBeNull();
});
});
});
16 changes: 14 additions & 2 deletions package/src/components/Attachment/Gallery.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useMemo } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import React, { useMemo, useRef } from 'react';
import { findNodeHandle, Pressable, StyleSheet, Text, View } from 'react-native';

import type { Attachment, LocalMessage } from 'stream-chat';

Expand All @@ -14,6 +14,7 @@ import {

import { openUrlSafely } from './utils/openUrlSafely';

import { useA11yLabel } from '../../a11y/hooks/useA11yLabel';
import { useTranslationContext } from '../../contexts';
import { useChatConfigContext } from '../../contexts/chatConfigContext/ChatConfigContext';
import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext';
Expand Down Expand Up @@ -236,12 +237,19 @@ const GalleryThumbnail = ({
} = useTheme();
const { t } = useTranslationContext();
const styles = useStyles();
const isVideo = thumbnail.type === FileTypes.Video;
const thumbnailAccessibilityLabel = useA11yLabel(
isVideo ? 'a11y/Gallery Video' : 'a11y/Gallery Image',
);
const thumbnailAccessibilityHint = useA11yLabel('a11y/Double tap to open');
const thumbnailRef = useRef<View>(null);
const openImageViewer = () => {
if (!message) {
return;
}
imageGalleryStateStore.openImageGallery({
messages: [message],
requesterNode: findNodeHandle(thumbnailRef.current),
selectedAttachmentUrl: thumbnail.url,
});
setOverlay('gallery');
Expand All @@ -260,8 +268,12 @@ const GalleryThumbnail = ({
};
return (
<Pressable
accessibilityHint={thumbnailAccessibilityHint}
accessibilityLabel={thumbnailAccessibilityLabel}
accessibilityRole='button'
disabled={preventPress}
key={`gallery-item-${message.id}/${colIndex}/${rowIndex}/${imagesAndVideos.length}`}
ref={thumbnailRef}
onLongPress={(event) => {
if (onLongPress) {
onLongPress({
Expand Down
27 changes: 25 additions & 2 deletions package/src/components/Channel/__tests__/ownCapabilities.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { FlatList } from 'react-native';

import { SafeAreaProvider } from 'react-native-safe-area-context';

import { act, fireEvent, render, waitFor } from '@testing-library/react-native';
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react-native';
import type { Channel as ChannelType, LocalMessage, StreamChat } from 'stream-chat';

import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider';
Expand All @@ -16,6 +16,7 @@ import { generateMessage } from '../../../mock-builders/generator/message';
import { generateUser } from '../../../mock-builders/generator/user';
import { getTestClientWithUser } from '../../../mock-builders/mock';
import { registerNativeHandlers } from '../../../native';
import { closeOverlay, finalizeCloseOverlay } from '../../../state-store';
import { Channel } from '../../Channel/Channel';
import { Chat } from '../../Chat/Chat';
import { MessageComposer } from '../../MessageInput/MessageComposer';
Expand Down Expand Up @@ -49,6 +50,15 @@ describe('Own capabilities', () => {
});
});

afterEach(() => {
cleanup();
// The overlay state-store is module-level, so an overlay opened by a
// previous test would still be `active` when the next test mounts.
// Reset it so each test starts with `isActive=false`.
closeOverlay();
finalizeCloseOverlay();
});

const getComponent = (props: Partial<React.ComponentProps<typeof Channel>> = {}) => (
<SafeAreaProvider>
<OverlayProvider>
Expand Down Expand Up @@ -76,7 +86,20 @@ describe('Own capabilities', () => {
targetMessage: LocalMessage,
props: Partial<React.ComponentProps<typeof Channel>> = {},
) => {
const { findByTestId, queryByLabelText, queryByText, unmount } = render(getComponent(props));
const {
findByTestId,
queryByLabelText: rawQueryByLabelText,
queryByText,
unmount,
} = render(getComponent(props));
// After the overlay opens, the host layer's `accessibilityViewIsModal`
// marks the chat tree as hidden to RNTL. The message action list is
// Portal-teleported into the overlay but its React parent stays inside
// the chat, so the visible-elements filter excludes it. The list IS
// rendered (and visible in production); we pass `includeHiddenElements`
// to look past the modal-sibling heuristic.
const queryByLabelText: typeof rawQueryByLabelText = (text, options) =>
rawQueryByLabelText(text, { includeHiddenElements: true, ...options });
await waitFor(() => queryByText(targetMessage.text as string));

act(() => {
Expand Down
20 changes: 17 additions & 3 deletions package/src/components/ChannelPreview/ChannelPreviewStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useTheme } from '../../contexts/themeContext/ThemeContext';
import { useTranslationContext } from '../../contexts/translationContext/TranslationContext';

import { primitives } from '../../theme';
import { getDateString } from '../../utils/i18n/getDateString';
import { getDateString, getDateStringForA11y } from '../../utils/i18n/getDateString';

export type ChannelPreviewStatusProps = Pick<
ChannelPreviewViewPropsWithContext,
Expand All @@ -19,7 +19,7 @@ export type ChannelPreviewStatusProps = Pick<

export const ChannelPreviewStatus = (props: ChannelPreviewStatusProps) => {
const { formatLatestMessageDate, lastMessage } = props;
const { t, tDateTimeParser } = useTranslationContext();
const { t, tDateTimeParser, userLanguage } = useTranslationContext();
const styles = useStyles();

const created_at = lastMessage?.created_at;
Expand All @@ -36,11 +36,25 @@ export const ChannelPreviewStatus = (props: ChannelPreviewStatusProps) => {
[created_at, t, tDateTimeParser],
);

const a11yDate = useMemo(
() =>
getDateStringForA11y({
calendarFormatOverrides: { sameDay: 'LT' },
date: created_at,
tDateTimeParser,
userLanguage,
}),
[created_at, tDateTimeParser, userLanguage],
);

const visibleDate =
formatLatestMessageDate && latestMessageDate
? formatLatestMessageDate(latestMessageDate).toString()
: formattedDate;
const labelParams = useMemo(() => ({ date: visibleDate ?? '' }), [visibleDate]);
const labelParams = useMemo(
() => ({ date: a11yDate ?? visibleDate ?? '' }),
[a11yDate, visibleDate],
);
const accessibilityLabel = useA11yLabel('a11y/Last message {{date}}', labelParams);

return (
Expand Down
86 changes: 82 additions & 4 deletions package/src/components/ImageGallery/ImageGallery.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Image, ImageStyle, StyleSheet, ViewStyle } from 'react-native';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
AccessibilityInfo,
Image,
ImageStyle,
Platform,
StyleSheet,
ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';

import Animated, {
Expand All @@ -21,6 +28,8 @@ import type {

import { useImageGalleryGestures } from './hooks/useImageGalleryGestures';

import { useA11yLabel } from '../../a11y/hooks/useA11yLabel';
import { useAccessibilityContext } from '../../contexts/accessibilityContext/AccessibilityContext';
import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext';
import {
ImageGalleryProviderProps,
Expand Down Expand Up @@ -280,13 +289,82 @@ export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) =>
setIsGridViewVisible(true);
};

const { enabled: isAccessibilityEnabled } = useAccessibilityContext();
const assetsCount = assets.length;
const isAdjustable = isAccessibilityEnabled;
const accessibilityValueParams = useMemo(
() => ({ count: assetsCount, position: currentIndex + 1 }),
[currentIndex, assetsCount],
);
const accessibilityValueText = useA11yLabel(
'a11y/{{position}} of {{count}}',
accessibilityValueParams,
);
const accessibilityValue = useMemo(
() => (accessibilityValueText ? { text: accessibilityValueText } : undefined),
[accessibilityValueText],
);
const adjustableActions = useMemo(
() =>
isAdjustable ? [{ name: 'increment' as const }, { name: 'decrement' as const }] : undefined,
[isAdjustable],
);

const onAccessibilityAction = useCallback(
(event: { nativeEvent: { actionName: string } }) => {
if (!isAccessibilityEnabled) return;
const latest = imageGalleryStateStore.state.getLatestValue();
const latestCount = latest.assets.length;
const latestIndex = latest.currentIndex;
if (latestCount <= 1) return;
if (event.nativeEvent.actionName === 'increment') {
if (latestIndex < latestCount - 1) {
imageGalleryStateStore.currentIndex = latestIndex + 1;
}
} else if (event.nativeEvent.actionName === 'decrement') {
if (latestIndex > 0) {
imageGalleryStateStore.currentIndex = latestIndex - 1;
}
}
},
[imageGalleryStateStore, isAccessibilityEnabled],
);

useEffect(() => {
return () => {
const handle = imageGalleryStateStore.requesterNode;
if (handle == null) return;
imageGalleryStateStore.requesterNode = null;
// Because of the fact that iOS and Android handle supressing
// the content underneath differently, we have to wait a frame
// before iOS is allowed to attempt to refocus (it takes a frame
// for the native a11y tree to become aware that it no longer has
// an accessibilityViewIsModal sibling).
if (Platform.OS === 'android') {
AccessibilityInfo.setAccessibilityFocus(handle);
} else {
requestAnimationFrame(() => {
AccessibilityInfo.setAccessibilityFocus(handle);
});
}
};
}, [imageGalleryStateStore]);

return (
<Animated.View
accessibilityLabel='Image Gallery'
accessibilityViewIsModal
pointerEvents={'auto'}
style={[StyleSheet.absoluteFill, showScreenStyle]}
>
<Animated.View style={[StyleSheet.absoluteFill, containerBackground]} />
<Animated.View
accessible
accessibilityActions={adjustableActions}
accessibilityLabel='Image Gallery'
accessibilityRole={isAdjustable ? 'adjustable' : undefined}
accessibilityValue={isAdjustable ? accessibilityValue : undefined}
onAccessibilityAction={isAdjustable ? onAccessibilityAction : undefined}
style={[StyleSheet.absoluteFill, containerBackground]}
/>
<GestureDetector gesture={Gesture.Simultaneous(singleTap, doubleTap, pinch, pan)}>
<Animated.View style={StyleSheet.absoluteFill}>
<Animated.View
Expand Down
Loading
Loading