Improve dialogs a11y (#3094)

* Improve a11y on ios

* Format

* Remove android

* Fix android
zio/stable
Eric Bailey 2024-03-04 15:37:11 -06:00 committed by GitHub
parent ebd279ed68
commit 6c9d6f5b05
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 91 additions and 54 deletions

View File

@ -15,7 +15,7 @@ import {useTheme, atoms as a, flatten} from '#/alf'
import {Portal} from '#/components/Portal'
import {createInput} from '#/components/forms/TextField'
import {logger} from '#/logger'
import {useDialogStateContext} from '#/state/dialogs'
import {useDialogStateControlContext} from '#/state/dialogs'
import {
DialogOuterProps,
@ -82,7 +82,7 @@ export function Outer({
const hasSnapPoints = !!sheetOptions.snapPoints
const insets = useSafeAreaInsets()
const closeCallback = React.useRef<() => void>()
const {openDialogs} = useDialogStateContext()
const {setDialogIsOpen} = useDialogStateControlContext()
/*
* Used to manage open/closed, but index is otherwise handled internally by `BottomSheet`
@ -96,11 +96,11 @@ export function Outer({
const open = React.useCallback<DialogControlProps['open']>(
({index} = {}) => {
openDialogs.current.add(control.id)
setDialogIsOpen(control.id, true)
// can be set to any index of `snapPoints`, but `0` is the first i.e. "open"
setOpenIndex(index || 0)
},
[setOpenIndex, openDialogs, control.id],
[setOpenIndex, setDialogIsOpen, control.id],
)
const close = React.useCallback<DialogControlProps['close']>(cb => {
@ -133,12 +133,12 @@ export function Outer({
closeCallback.current = undefined
}
openDialogs.current.delete(control.id)
setDialogIsOpen(control.id, false)
onClose?.()
setOpenIndex(-1)
}
},
[onClose, setOpenIndex, openDialogs, control.id],
[onClose, setOpenIndex, setDialogIsOpen, control.id],
)
const context = React.useMemo(() => ({close}), [close])
@ -146,38 +146,45 @@ export function Outer({
return (
isOpen && (
<Portal>
<BottomSheet
enableDynamicSizing={!hasSnapPoints}
enablePanDownToClose
keyboardBehavior="interactive"
android_keyboardInputMode="adjustResize"
keyboardBlurBehavior="restore"
topInset={insets.top}
{...sheetOptions}
snapPoints={sheetOptions.snapPoints || ['100%']}
ref={sheet}
index={openIndex}
backgroundStyle={{backgroundColor: 'transparent'}}
backdropComponent={Backdrop}
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
handleStyle={{display: 'none'}}
onChange={onChange}>
<Context.Provider value={context}>
<View
style={[
a.absolute,
a.inset_0,
t.atoms.bg,
{
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
height: Dimensions.get('window').height * 2,
},
]}
/>
{children}
</Context.Provider>
</BottomSheet>
<View
// iOS
accessibilityViewIsModal
// Android
importantForAccessibility="yes"
style={[a.absolute, a.inset_0]}>
<BottomSheet
enableDynamicSizing={!hasSnapPoints}
enablePanDownToClose
keyboardBehavior="interactive"
android_keyboardInputMode="adjustResize"
keyboardBlurBehavior="restore"
topInset={insets.top}
{...sheetOptions}
snapPoints={sheetOptions.snapPoints || ['100%']}
ref={sheet}
index={openIndex}
backgroundStyle={{backgroundColor: 'transparent'}}
backdropComponent={Backdrop}
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
handleStyle={{display: 'none'}}
onChange={onChange}>
<Context.Provider value={context}>
<View
style={[
a.absolute,
a.inset_0,
t.atoms.bg,
{
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
height: Dimensions.get('window').height * 2,
},
]}
/>
{children}
</Context.Provider>
</BottomSheet>
</View>
</Portal>
)
)

View File

@ -12,7 +12,7 @@ import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types'
import {Context} from '#/components/Dialog/context'
import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {useDialogStateContext} from '#/state/dialogs'
import {useDialogStateControlContext} from '#/state/dialogs'
export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
export * from '#/components/Dialog/types'
@ -30,21 +30,21 @@ export function Outer({
const {gtMobile} = useBreakpoints()
const [isOpen, setIsOpen] = React.useState(false)
const [isVisible, setIsVisible] = React.useState(true)
const {openDialogs} = useDialogStateContext()
const {setDialogIsOpen} = useDialogStateControlContext()
const open = React.useCallback(() => {
setIsOpen(true)
openDialogs.current.add(control.id)
}, [setIsOpen, openDialogs, control.id])
setDialogIsOpen(control.id, true)
}, [setIsOpen, setDialogIsOpen, control.id])
const close = React.useCallback(async () => {
setIsVisible(false)
await new Promise(resolve => setTimeout(resolve, 150))
setIsOpen(false)
setIsVisible(true)
openDialogs.current.delete(control.id)
setDialogIsOpen(control.id, false)
onClose?.()
}, [onClose, setIsOpen, openDialogs, control.id])
}, [onClose, setIsOpen, setDialogIsOpen, control.id])
useImperativeHandle(
control.ref,

View File

@ -13,20 +13,20 @@ const DialogContext = React.createContext<{
* The currently open dialogs, referenced by their IDs, generated from
* `useId`.
*/
openDialogs: React.MutableRefObject<Set<string>>
openDialogs: string[]
}>({
activeDialogs: {
current: new Map(),
},
openDialogs: {
current: new Set(),
},
openDialogs: [],
})
const DialogControlContext = React.createContext<{
closeAllDialogs(): boolean
setDialogIsOpen(id: string, isOpen: boolean): void
}>({
closeAllDialogs: () => false,
setDialogIsOpen: () => {},
})
export function useDialogStateContext() {
@ -41,15 +41,31 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const activeDialogs = React.useRef<
Map<string, React.MutableRefObject<DialogControlRefProps>>
>(new Map())
const openDialogs = React.useRef<Set<string>>(new Set())
const [openDialogs, setOpenDialogs] = React.useState<string[]>([])
const closeAllDialogs = React.useCallback(() => {
activeDialogs.current.forEach(dialog => dialog.current.close())
return openDialogs.current.size > 0
}, [])
return openDialogs.length > 0
}, [openDialogs])
const context = React.useMemo(() => ({activeDialogs, openDialogs}), [])
const controls = React.useMemo(() => ({closeAllDialogs}), [closeAllDialogs])
const setDialogIsOpen = React.useCallback(
(id: string, isOpen: boolean) => {
setOpenDialogs(prev => {
const filtered = prev.filter(dialogId => dialogId !== id) as string[]
return isOpen ? [...filtered, id] : filtered
})
},
[setOpenDialogs],
)
const context = React.useMemo(
() => ({activeDialogs, openDialogs}),
[openDialogs],
)
const controls = React.useMemo(
() => ({closeAllDialogs, setDialogIsOpen}),
[closeAllDialogs, setDialogIsOpen],
)
return (
<DialogContext.Provider value={context}>

View File

@ -30,6 +30,7 @@ import {useCloseAnyActiveElement} from '#/state/util'
import * as notifications from 'lib/notifications/notifications'
import {Outlet as PortalOutlet} from '#/components/Portal'
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
import {useDialogStateContext} from '#/state/dialogs'
function ShellInner() {
const isDrawerOpen = useIsDrawerOpen()
@ -55,6 +56,7 @@ function ShellInner() {
const closeAnyActiveElement = useCloseAnyActiveElement()
// start undefined
const currentAccountDid = React.useRef<string | undefined>(undefined)
const {openDialogs} = useDialogStateContext()
React.useEffect(() => {
let listener = {remove() {}}
@ -78,9 +80,21 @@ function ShellInner() {
}
}, [currentAccount])
/**
* The counterpart to `accessibilityViewIsModal` for Android. This property
* applies to the parent of all non-modal views, and prevents TalkBack from
* navigating within content beneath an open dialog.
*
* @see https://reactnative.dev/docs/accessibility#importantforaccessibility-android
*/
const importantForAccessibility =
openDialogs.length > 0 ? 'no-hide-descendants' : undefined
return (
<>
<View style={containerPadding}>
<View
style={containerPadding}
importantForAccessibility={importantForAccessibility}>
<ErrorBoundary>
<Drawer
renderDrawerContent={renderDrawerContent}