about summary refs log tree commit diff
path: root/src/components/FocusScope/index.tsx
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2025-08-06 15:15:52 -0500
committerGitHub <noreply@github.com>2025-08-06 15:15:52 -0500
commit328aa2be9482f77cb1cf86c5d227fdcee9981b16 (patch)
tree27174f10e0fe80288c0cd6907f8686486131d082 /src/components/FocusScope/index.tsx
parentfd37d92f85ddf0f075a67c4e9b2d85bef38f1835 (diff)
downloadvoidsky-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.tsx144
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>
+  )
+}