Rework dialog and add native FocusScope

This commit is contained in:
Eric Bailey
2025-08-01 15:23:30 -05:00
parent c6a82c8e8a
commit 2c359d3528
9 changed files with 178 additions and 46 deletions
+13 -10
View File
@@ -72,6 +72,7 @@ import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs' import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PortalProvider} from '#/components/Portal' import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
import {Provider as BlockingAnnouncementsPortalProvider} from '#/components/dialogs/BlockingAnnouncements'
import {Splash} from '#/Splash' import {Splash} from '#/Splash'
import {BottomSheetProvider} from '../modules/bottom-sheet' import {BottomSheetProvider} from '../modules/bottom-sheet'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
@@ -220,16 +221,18 @@ function App() {
<ModalStateProvider> <ModalStateProvider>
<DialogStateProvider> <DialogStateProvider>
<LightboxStateProvider> <LightboxStateProvider>
<PortalProvider> <BlockingAnnouncementsPortalProvider>
<BottomSheetProvider> <PortalProvider>
<StarterPackProvider> <BottomSheetProvider>
<SafeAreaProvider <StarterPackProvider>
initialMetrics={initialWindowMetrics}> <SafeAreaProvider
<InnerApp /> initialMetrics={initialWindowMetrics}>
</SafeAreaProvider> <InnerApp />
</StarterPackProvider> </SafeAreaProvider>
</BottomSheetProvider> </StarterPackProvider>
</PortalProvider> </BottomSheetProvider>
</PortalProvider>
</BlockingAnnouncementsPortalProvider>
</LightboxStateProvider> </LightboxStateProvider>
</DialogStateProvider> </DialogStateProvider>
</ModalStateProvider> </ModalStateProvider>
+3
View File
@@ -60,6 +60,7 @@ import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialo
import {Provider as PortalProvider} from '#/components/Portal' import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext' import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext'
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
import {Provider as BlockingAnnouncementsPortalProvider} from '#/components/dialogs/BlockingAnnouncements'
import {ToastContainer} from '#/components/Toast' import {ToastContainer} from '#/components/Toast'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder' import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder'
@@ -196,11 +197,13 @@ function App() {
<ModalStateProvider> <ModalStateProvider>
<DialogStateProvider> <DialogStateProvider>
<LightboxStateProvider> <LightboxStateProvider>
<BlockingAnnouncementsPortalProvider>
<PortalProvider> <PortalProvider>
<StarterPackProvider> <StarterPackProvider>
<InnerApp /> <InnerApp />
</StarterPackProvider> </StarterPackProvider>
</PortalProvider> </PortalProvider>
</BlockingAnnouncementsPortalProvider>
</LightboxStateProvider> </LightboxStateProvider>
</DialogStateProvider> </DialogStateProvider>
</ModalStateProvider> </ModalStateProvider>
+106
View File
@@ -0,0 +1,106 @@
import {
Children,
cloneElement,
ReactNode,
useRef,
useMemo,
useCallback,
useEffect,
isValidElement,
FunctionComponentElement,
} from 'react'
import {
AccessibilityInfo,
Pressable,
View,
Text,
findNodeHandle,
} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useA11y} from '#/state/a11y'
export function FocusScope({children}: {children: ReactNode}) {
const {screenReaderEnabled} = useA11y()
return screenReaderEnabled ? <FocusTrap>{children}</FocusTrap> : children
}
function FocusTrap({children}: {children: ReactNode}) {
const {_} = useLingui()
const child = useRef<View>(null)
const content = useMemo(() => {
return Children.toArray(children).map((node, i) => {
if (i === 0 && isValidElement(node)) {
return cloneElement(node as FunctionComponentElement<any>, {
ref: child,
})
}
return node
})
}, [children])
const focus = useCallback((ref: View | null) => {
if (!ref) return
const node = findNodeHandle(ref)
if (node) {
AccessibilityInfo.setAccessibilityFocus(node)
}
}, [])
useEffect(() => {
setTimeout(() => {
focus(child.current)
}, 1e3)
}, [focus])
return (
<>
<Pressable
accessible
accessibilityLabel={_(
msg`You've reached the start of the active content. Please go back, or activate to focus the first item.`,
)}
accessibilityActions={[{name: 'activate', label: 'activate'}]}
onAccessibilityAction={event => {
switch (event.nativeEvent.actionName) {
case 'activate': {
focus(child.current)
}
}
}}>
<Noop />
</Pressable>
{content}
<Pressable
accessibilityLabel={_(
msg`You've reached the end of the active content. Please go back, or activate to go back to the beginning.`,
)}
accessibilityActions={[{name: 'activate', label: 'activate'}]}
onAccessibilityAction={event => {
switch (event.nativeEvent.actionName) {
case 'activate': {
focus(child.current)
}
}
}}>
<Noop />
</Pressable>
</>
)
}
function Noop() {
return (
<Text
accessible={false}
style={{
height: 1,
opacity: 0,
}}>
{' '}
</Text>
)
}
@@ -5,7 +5,8 @@ import {View, ScrollView, useWindowDimensions} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {LinearGradient} from 'expo-linear-gradient' import {LinearGradient} from 'expo-linear-gradient'
import {isNative} from '#/platform/detection' import {FocusScope} from '#/components/FocusScope'
import {isNative, isAndroid} from '#/platform/detection'
import {atoms as a, useTheme, useBreakpoints, web, flatten} from '#/alf' import {atoms as a, useTheme, useBreakpoints, web, flatten} from '#/alf'
import {transparentifyColor} from '#/alf/util/colorGeneration' import {transparentifyColor} from '#/alf/util/colorGeneration'
import {useA11y} from '#/state/a11y' import {useA11y} from '#/state/a11y'
@@ -49,7 +50,7 @@ export function AnnouncementDialog({
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
return ( return (
<> <View style={[a.fixed, a.inset_0, {zIndex: 9999}]}>
<View style={[a.fixed, a.inset_0, !reduceMotionEnabled && a.fade_in]}> <View style={[a.fixed, a.inset_0, !reduceMotionEnabled && a.fade_in]}>
<LinearGradient <LinearGradient
colors={[ colors={[
@@ -94,8 +95,6 @@ export function AnnouncementDialog({
}, },
], ],
]}> ]}>
{/* <FocusScope.FocusScope loop asChild trapped> */}
{!gtPhone && ( {!gtPhone && (
<View <View
style={[ style={[
@@ -117,31 +116,33 @@ export function AnnouncementDialog({
</View> </View>
)} )}
<View <FocusScope>
role="dialog" <View
aria-role="dialog" accessible={isAndroid}
aria-label={label} role="dialog"
style={flatten([ aria-role="dialog"
a.relative, aria-label={label}
a.w_full, style={flatten([
a.p_2xl, a.relative,
t.atoms.bg, a.w_full,
!reduceMotionEnabled && a.zoom_fade_in, a.p_2xl,
gtPhone && [ t.atoms.bg,
a.rounded_md, !reduceMotionEnabled && a.zoom_fade_in,
a.border, gtPhone && [
t.atoms.shadow_lg, a.rounded_md,
t.atoms.border_contrast_low, a.border,
web({ t.atoms.shadow_lg,
maxWidth: 420, t.atoms.border_contrast_low,
}), web({
], maxWidth: 420,
])}> }),
{children} ],
</View> ])}>
{/* </FocusScope.FocusScope> */} {children}
</View>
</FocusScope>
</View> </View>
</ScrollView> </ScrollView>
</> </View>
) )
} }
@@ -12,7 +12,10 @@ import {
} from '#/components/dialogs/BlockingAnnouncements/common' } from '#/components/dialogs/BlockingAnnouncements/common'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import {Span, Text} from '#/components/Typography' import {Span, Text} from '#/components/Typography'
import {AnnouncementDialog, useAnnouncementDialogContext} from '#/components/dialogs/BlockingAnnouncements/AnnouncementDialog' import {
AnnouncementDialog,
useAnnouncementDialogContext,
} from '#/components/dialogs/BlockingAnnouncements/AnnouncementDialog'
export function useLocalState() { export function useLocalState() {
return useAnnouncementState({ return useAnnouncementState({
@@ -111,6 +114,8 @@ export function Announcement() {
<View style={[a.w_full, a.gap_md]}> <View style={[a.w_full, a.gap_md]}>
<Button <Button
label={_(msg`Continue`)} label={_(msg`Continue`)}
accessibilityHint={_(msg`Tap to acknowledge that you understand and
agree to these updates and continue using Bluesky`)}
color="primary" color="primary"
size="large" size="large"
onPress={handleClose}> onPress={handleClose}>
@@ -1,5 +1,12 @@
import {AnnouncementDialogOuter} from '#/components/dialogs/BlockingAnnouncements/AnnouncementDialog' import {AnnouncementDialogOuter} from '#/components/dialogs/BlockingAnnouncements/AnnouncementDialog'
import * as PolicyUpdate20250801 from '#/components/dialogs/BlockingAnnouncements/PolicyUpdate20250801' import * as PolicyUpdate20250801 from '#/components/dialogs/BlockingAnnouncements/PolicyUpdate20250801'
import {createPortalGroup} from '#/components/Portal'
const portalGroup = createPortalGroup()
export const Provider = portalGroup.Provider
export const Outlet = portalGroup.Outlet
export const Portal = portalGroup.Portal
export function BlockingAnnouncements() { export function BlockingAnnouncements() {
const policyUpdate20250801 = PolicyUpdate20250801.useLocalState() const policyUpdate20250801 = PolicyUpdate20250801.useLocalState()
@@ -12,8 +19,10 @@ export function BlockingAnnouncements() {
if (policyUpdate20250801.completed) return null if (policyUpdate20250801.completed) return null
return ( return (
<AnnouncementDialogOuter> <Portal>
<PolicyUpdate20250801.Announcement /> <AnnouncementDialogOuter>
</AnnouncementDialogOuter> <PolicyUpdate20250801.Announcement />
</AnnouncementDialogOuter>
</Portal>
) )
} }
@@ -43,6 +43,7 @@ import {atoms as a, useLayoutBreakpoints} from '#/alf'
import {BottomBarWeb} from './bottom-bar/BottomBarWeb' import {BottomBarWeb} from './bottom-bar/BottomBarWeb'
import {DesktopLeftNav} from './desktop/LeftNav' import {DesktopLeftNav} from './desktop/LeftNav'
import {DesktopRightNav} from './desktop/RightNav' import {DesktopRightNav} from './desktop/RightNav'
import {BlockingAnnouncements} from '#/components/dialogs/BlockingAnnouncements'
type NativeStackNavigationOptionsWithAuth = NativeStackNavigationOptions & { type NativeStackNavigationOptionsWithAuth = NativeStackNavigationOptions & {
requireAuth?: boolean requireAuth?: boolean
@@ -167,6 +168,7 @@ function NativeStackNavigator({
{!isMobile && <DesktopRightNav routeName={activeRoute.name} />} {!isMobile && <DesktopRightNav routeName={activeRoute.name} />}
</> </>
)} )}
<BlockingAnnouncements />
</NavigationContent> </NavigationContent>
) )
} }
+4 -2
View File
@@ -26,13 +26,13 @@ import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {atoms as a, select, useTheme} from '#/alf' import {atoms as a, select, useTheme} from '#/alf'
import {setSystemUITheme} from '#/alf/util/systemUI' import {setSystemUITheme} from '#/alf/util/systemUI'
import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog' import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
import {BlockingAnnouncements} from '#/components/dialogs/BlockingAnnouncements'
import {EmailDialog} from '#/components/dialogs/EmailDialog' import {EmailDialog} from '#/components/dialogs/EmailDialog'
import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent' import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent'
import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
import {MutedWordsDialog} from '#/components/dialogs/MutedWords' import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
import {SigninDialog} from '#/components/dialogs/Signin' import {SigninDialog} from '#/components/dialogs/Signin'
import {Outlet as PortalOutlet} from '#/components/Portal' import {Outlet as PortalOutlet} from '#/components/Portal'
import {Outlet as BlockingAccouncementsPortalOutlet} from '#/components/dialogs/BlockingAnnouncements'
import {RoutesContainer, TabsNavigator} from '#/Navigation' import {RoutesContainer, TabsNavigator} from '#/Navigation'
import {BottomSheetOutlet} from '../../../modules/bottom-sheet' import {BottomSheetOutlet} from '../../../modules/bottom-sheet'
import {updateActiveViewAsync} from '../../../modules/expo-bluesky-swiss-army/src/VisibilityView' import {updateActiveViewAsync} from '../../../modules/expo-bluesky-swiss-army/src/VisibilityView'
@@ -163,7 +163,9 @@ function ShellInner() {
<Lightbox /> <Lightbox />
<PortalOutlet /> <PortalOutlet />
<BottomSheetOutlet /> <BottomSheetOutlet />
<BlockingAnnouncements />
{/* MUST BE LAST */}
<BlockingAccouncementsPortalOutlet />
</> </>
) )
} }
+3 -2
View File
@@ -18,7 +18,7 @@ import {ModalsContainer} from '#/view/com/modals/Modal'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {atoms as a, select, useTheme} from '#/alf' import {atoms as a, select, useTheme} from '#/alf'
import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog' import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
import {BlockingAnnouncements} from '#/components/dialogs/BlockingAnnouncements' import {Outlet as BlockingAccouncementsPortalOutlet} from '#/components/dialogs/BlockingAnnouncements'
import {EmailDialog} from '#/components/dialogs/EmailDialog' import {EmailDialog} from '#/components/dialogs/EmailDialog'
import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
import {MutedWordsDialog} from '#/components/dialogs/MutedWords' import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
@@ -115,7 +115,8 @@ function ShellInner() {
</> </>
)} )}
<BlockingAnnouncements /> {/* MUST BE LAST */}
<BlockingAccouncementsPortalOutlet />
</> </>
) )
} }