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 {Portal} from '#/components/Portal'
import {createInput} from '#/components/forms/TextField' import {createInput} from '#/components/forms/TextField'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useDialogStateContext} from '#/state/dialogs' import {useDialogStateControlContext} from '#/state/dialogs'
import { import {
DialogOuterProps, DialogOuterProps,
@ -82,7 +82,7 @@ export function Outer({
const hasSnapPoints = !!sheetOptions.snapPoints const hasSnapPoints = !!sheetOptions.snapPoints
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const closeCallback = React.useRef<() => void>() const closeCallback = React.useRef<() => void>()
const {openDialogs} = useDialogStateContext() const {setDialogIsOpen} = useDialogStateControlContext()
/* /*
* Used to manage open/closed, but index is otherwise handled internally by `BottomSheet` * 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']>( const open = React.useCallback<DialogControlProps['open']>(
({index} = {}) => { ({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" // can be set to any index of `snapPoints`, but `0` is the first i.e. "open"
setOpenIndex(index || 0) setOpenIndex(index || 0)
}, },
[setOpenIndex, openDialogs, control.id], [setOpenIndex, setDialogIsOpen, control.id],
) )
const close = React.useCallback<DialogControlProps['close']>(cb => { const close = React.useCallback<DialogControlProps['close']>(cb => {
@ -133,12 +133,12 @@ export function Outer({
closeCallback.current = undefined closeCallback.current = undefined
} }
openDialogs.current.delete(control.id) setDialogIsOpen(control.id, false)
onClose?.() onClose?.()
setOpenIndex(-1) setOpenIndex(-1)
} }
}, },
[onClose, setOpenIndex, openDialogs, control.id], [onClose, setOpenIndex, setDialogIsOpen, control.id],
) )
const context = React.useMemo(() => ({close}), [close]) const context = React.useMemo(() => ({close}), [close])
@ -146,6 +146,12 @@ export function Outer({
return ( return (
isOpen && ( isOpen && (
<Portal> <Portal>
<View
// iOS
accessibilityViewIsModal
// Android
importantForAccessibility="yes"
style={[a.absolute, a.inset_0]}>
<BottomSheet <BottomSheet
enableDynamicSizing={!hasSnapPoints} enableDynamicSizing={!hasSnapPoints}
enablePanDownToClose enablePanDownToClose
@ -178,6 +184,7 @@ export function Outer({
{children} {children}
</Context.Provider> </Context.Provider>
</BottomSheet> </BottomSheet>
</View>
</Portal> </Portal>
) )
) )

View File

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

View File

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

View File

@ -30,6 +30,7 @@ import {useCloseAnyActiveElement} from '#/state/util'
import * as notifications from 'lib/notifications/notifications' import * as notifications from 'lib/notifications/notifications'
import {Outlet as PortalOutlet} from '#/components/Portal' import {Outlet as PortalOutlet} from '#/components/Portal'
import {MutedWordsDialog} from '#/components/dialogs/MutedWords' import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
import {useDialogStateContext} from '#/state/dialogs'
function ShellInner() { function ShellInner() {
const isDrawerOpen = useIsDrawerOpen() const isDrawerOpen = useIsDrawerOpen()
@ -55,6 +56,7 @@ function ShellInner() {
const closeAnyActiveElement = useCloseAnyActiveElement() const closeAnyActiveElement = useCloseAnyActiveElement()
// start undefined // start undefined
const currentAccountDid = React.useRef<string | undefined>(undefined) const currentAccountDid = React.useRef<string | undefined>(undefined)
const {openDialogs} = useDialogStateContext()
React.useEffect(() => { React.useEffect(() => {
let listener = {remove() {}} let listener = {remove() {}}
@ -78,9 +80,21 @@ function ShellInner() {
} }
}, [currentAccount]) }, [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 ( return (
<> <>
<View style={containerPadding}> <View
style={containerPadding}
importantForAccessibility={importantForAccessibility}>
<ErrorBoundary> <ErrorBoundary>
<Drawer <Drawer
renderDrawerContent={renderDrawerContent} renderDrawerContent={renderDrawerContent}