forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {
2 Children,
3 cloneElement,
4 isValidElement,
5 useCallback,
6 useEffect,
7 useMemo,
8 useRef,
9} from 'react'
10import {
11 AccessibilityInfo,
12 findNodeHandle,
13 Pressable,
14 Text,
15 View,
16} from 'react-native'
17import {msg} from '@lingui/macro'
18import {useLingui} from '@lingui/react'
19
20import {useA11y} from '#/state/a11y'
21
22/**
23 * Conditionally wraps children in a `FocusTrap` component based on whether
24 * screen reader support is enabled. THIS SHOULD BE USED SPARINGLY, only when
25 * no better option is available.
26 */
27export function FocusScope({children}: {children: React.ReactNode}) {
28 const {screenReaderEnabled} = useA11y()
29
30 return screenReaderEnabled ? <FocusTrap>{children}</FocusTrap> : children
31}
32
33/**
34 * `FocusTrap` is intended as a last-ditch effort to ensure that users keep
35 * focus within a certain section of the app, like an overlay.
36 *
37 * It works by placing "guards" at the start and end of the active content.
38 * Then when the user reaches either of those guards, it will announce that
39 * they have reached the start or end of the content and tell them how to
40 * remain within the active content section.
41 */
42function FocusTrap({children}: {children: React.ReactNode}) {
43 const {_} = useLingui()
44 const child = useRef<View>(null)
45
46 /*
47 * Here we add a ref to the first child of this component. This currently
48 * overrides any ref already on that first child, so we throw an error here
49 * to prevent us from ever accidentally doing this.
50 */
51 const decoratedChildren = useMemo(() => {
52 return Children.toArray(children).map((node, i) => {
53 if (i === 0 && isValidElement(node)) {
54 const n = node as React.ReactElement<any>
55 if (n.props.ref !== undefined) {
56 throw new Error(
57 'FocusScope needs to override the ref on its first child.',
58 )
59 }
60 return cloneElement(n, {
61 ...n.props,
62 ref: child,
63 })
64 }
65 return node
66 })
67 }, [children])
68
69 const focusNode = useCallback((ref: View | null) => {
70 if (!ref) return
71 const node = findNodeHandle(ref)
72 if (node) {
73 AccessibilityInfo.setAccessibilityFocus(node)
74 }
75 }, [])
76
77 useEffect(() => {
78 setTimeout(() => {
79 focusNode(child.current)
80 }, 1e3)
81 }, [focusNode])
82
83 return (
84 <>
85 <Pressable
86 accessible
87 accessibilityLabel={_(
88 msg`You've reached the start of the active content.`,
89 )}
90 accessibilityHint={_(
91 msg`Please go back, or activate this element to return to the start of the active content.`,
92 )}
93 accessibilityActions={[{name: 'activate', label: 'activate'}]}
94 onAccessibilityAction={event => {
95 switch (event.nativeEvent.actionName) {
96 case 'activate': {
97 focusNode(child.current)
98 }
99 }
100 }}>
101 <Noop />
102 </Pressable>
103 <View
104 /**
105 * This property traps focus effectively on iOS, but not on Android.
106 */
107 accessibilityViewIsModal>
108 {decoratedChildren}
109 </View>
110 <Pressable
111 accessibilityLabel={_(
112 msg`You've reached the end of the active content.`,
113 )}
114 accessibilityHint={_(
115 msg`Please go back, or activate this element to return to the start of the active content.`,
116 )}
117 accessibilityActions={[{name: 'activate', label: 'activate'}]}
118 onAccessibilityAction={event => {
119 switch (event.nativeEvent.actionName) {
120 case 'activate': {
121 focusNode(child.current)
122 }
123 }
124 }}>
125 <Noop />
126 </Pressable>
127 </>
128 )
129}
130
131function Noop() {
132 return (
133 <Text
134 accessible={false}
135 style={{
136 height: 1,
137 opacity: 0,
138 }}>
139 {' '}
140 </Text>
141 )
142}