diff options
author | Eric Bailey <git@esb.lol> | 2025-08-06 15:15:52 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-08-06 15:15:52 -0500 |
commit | 328aa2be9482f77cb1cf86c5d227fdcee9981b16 (patch) | |
tree | 27174f10e0fe80288c0cd6907f8686486131d082 /src/components/FocusScope/index.tsx | |
parent | fd37d92f85ddf0f075a67c4e9b2d85bef38f1835 (diff) | |
download | voidsky-328aa2be9482f77cb1cf86c5d227fdcee9981b16.tar.zst |
[APP-1356] Policy update dialog (#8782)
* Add blocking announcement dialog feature * WIP custom dialog * Rework dialog and add native FocusScope * Lock scroll on web, fix backdrop * Add web FocusScope * Create custom Outlet for these announcements * Clean up FocusScope native impl * Comments * Some styling fixes * Handle screen reader specifically * Clean up state, remove Portal edits * Reorg, rename * Add syncing, tests * Revert dialog updates * Revert formatting * Delete unused file * Format * Add FullWindowOverlay * remove mmkv storage in debug btn * Add debug code * fix taps passing through on iOS * Reorg * Reorg, rename everything * Complete policy update after signup * Add logger * Move context around, unmount portals on native * Move a11y prop into FocusScope * Remove useMemo * Update dates * Move debug to dev settings * Unmount web portals until policy update completed * UPdate dates --------- Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Diffstat (limited to 'src/components/FocusScope/index.tsx')
-rw-r--r-- | src/components/FocusScope/index.tsx | 144 |
1 files changed, 144 insertions, 0 deletions
diff --git a/src/components/FocusScope/index.tsx b/src/components/FocusScope/index.tsx new file mode 100644 index 000000000..408381d5b --- /dev/null +++ b/src/components/FocusScope/index.tsx @@ -0,0 +1,144 @@ +import { + Children, + cloneElement, + isValidElement, + type ReactElement, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, +} from 'react' +import { + AccessibilityInfo, + findNodeHandle, + Pressable, + Text, + View, +} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useA11y} from '#/state/a11y' + +/** + * Conditionally wraps children in a `FocusTrap` component based on whether + * screen reader support is enabled. THIS SHOULD BE USED SPARINGLY, only when + * no better option is available. + */ +export function FocusScope({children}: {children: ReactNode}) { + const {screenReaderEnabled} = useA11y() + + return screenReaderEnabled ? <FocusTrap>{children}</FocusTrap> : children +} + +/** + * `FocusTrap` is intended as a last-ditch effort to ensure that users keep + * focus within a certain section of the app, like an overlay. + * + * It works by placing "guards" at the start and end of the active content. + * Then when the user reaches either of those guards, it will announce that + * they have reached the start or end of the content and tell them how to + * remain within the active content section. + */ +function FocusTrap({children}: {children: ReactNode}) { + const {_} = useLingui() + const child = useRef<View>(null) + + /* + * Here we add a ref to the first child of this component. This currently + * overrides any ref already on that first child, so we throw an error here + * to prevent us from ever accidentally doing this. + */ + const decoratedChildren = useMemo(() => { + return Children.toArray(children).map((node, i) => { + if (i === 0 && isValidElement(node)) { + const n = node as ReactElement<any> + if (n.props.ref !== undefined) { + throw new Error( + 'FocusScope needs to override the ref on its first child.', + ) + } + return cloneElement(n, { + ...n.props, + ref: child, + }) + } + return node + }) + }, [children]) + + const focusNode = useCallback((ref: View | null) => { + if (!ref) return + const node = findNodeHandle(ref) + if (node) { + AccessibilityInfo.setAccessibilityFocus(node) + } + }, []) + + useEffect(() => { + setTimeout(() => { + focusNode(child.current) + }, 1e3) + }, [focusNode]) + + return ( + <> + <Pressable + accessible + accessibilityLabel={_( + msg`You've reached the start of the active content.`, + )} + accessibilityHint={_( + msg`Please go back, or activate this element to return to the start of the active content.`, + )} + accessibilityActions={[{name: 'activate', label: 'activate'}]} + onAccessibilityAction={event => { + switch (event.nativeEvent.actionName) { + case 'activate': { + focusNode(child.current) + } + } + }}> + <Noop /> + </Pressable> + <View + /** + * This property traps focus effectively on iOS, but not on Android. + */ + accessibilityViewIsModal> + {decoratedChildren} + </View> + <Pressable + accessibilityLabel={_( + msg`You've reached the end of the active content.`, + )} + accessibilityHint={_( + msg`Please go back, or activate this element to return to the start of the active content.`, + )} + accessibilityActions={[{name: 'activate', label: 'activate'}]} + onAccessibilityAction={event => { + switch (event.nativeEvent.actionName) { + case 'activate': { + focusNode(child.current) + } + } + }}> + <Noop /> + </Pressable> + </> + ) +} + +function Noop() { + return ( + <Text + accessible={false} + style={{ + height: 1, + opacity: 0, + }}> + {' '} + </Text> + ) +} |