@tamagui/focus-scope 1.0.1-beta.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,294 @@
1
+ import { useCallbackRef } from '@radix-ui/react-use-callback-ref'
2
+ import { useComposedRefs } from '@tamagui/compose-refs'
3
+ import * as React from 'react'
4
+
5
+ import { FocusScopeProps } from './FocusScopeProps'
6
+
7
+ const AUTOFOCUS_ON_MOUNT = 'focusScope.autoFocusOnMount'
8
+ const AUTOFOCUS_ON_UNMOUNT = 'focusScope.autoFocusOnUnmount'
9
+ const EVENT_OPTIONS = { bubbles: false, cancelable: true }
10
+
11
+ type FocusableTarget = HTMLElement | { focus(): void }
12
+
13
+ /* -------------------------------------------------------------------------------------------------
14
+ * FocusScope
15
+ * -----------------------------------------------------------------------------------------------*/
16
+
17
+ const FOCUS_SCOPE_NAME = 'FocusScope'
18
+
19
+ type FocusScopeElement = HTMLDivElement
20
+
21
+ const FocusScope = React.forwardRef<FocusScopeElement, FocusScopeProps>((props, forwardedRef) => {
22
+ const {
23
+ loop = false,
24
+ trapped = false,
25
+ onMountAutoFocus: onMountAutoFocusProp,
26
+ onUnmountAutoFocus: onUnmountAutoFocusProp,
27
+ ...scopeProps
28
+ } = props
29
+ const [container, setContainer] = React.useState<HTMLElement | null>(null)
30
+ const onMountAutoFocus = useCallbackRef(onMountAutoFocusProp)
31
+ const onUnmountAutoFocus = useCallbackRef(onUnmountAutoFocusProp)
32
+ const lastFocusedElementRef = React.useRef<HTMLElement | null>(null)
33
+ const composedRefs = useComposedRefs(forwardedRef, (node) => setContainer(node))
34
+
35
+ const focusScope = React.useRef({
36
+ paused: false,
37
+ pause() {
38
+ this.paused = true
39
+ },
40
+ resume() {
41
+ this.paused = false
42
+ },
43
+ }).current
44
+
45
+ // Takes care of trapping focus if focus is moved outside programmatically for example
46
+ React.useEffect(() => {
47
+ if (trapped) {
48
+ function handleFocusIn(event: FocusEvent) {
49
+ if (focusScope.paused || !container) return
50
+ const target = event.target as HTMLElement | null
51
+ if (container.contains(target)) {
52
+ lastFocusedElementRef.current = target
53
+ } else {
54
+ focus(lastFocusedElementRef.current, { select: true })
55
+ }
56
+ }
57
+
58
+ function handleFocusOut(event: FocusEvent) {
59
+ if (focusScope.paused || !container) return
60
+ if (!container.contains(event.relatedTarget as HTMLElement | null)) {
61
+ focus(lastFocusedElementRef.current, { select: true })
62
+ }
63
+ }
64
+
65
+ document.addEventListener('focusin', handleFocusIn)
66
+ document.addEventListener('focusout', handleFocusOut)
67
+ return () => {
68
+ document.removeEventListener('focusin', handleFocusIn)
69
+ document.removeEventListener('focusout', handleFocusOut)
70
+ }
71
+ }
72
+ }, [trapped, container, focusScope.paused])
73
+
74
+ React.useEffect(() => {
75
+ if (container) {
76
+ focusScopesStack.add(focusScope)
77
+ const previouslyFocusedElement = document.activeElement as HTMLElement | null
78
+ const hasFocusedCandidate = container.contains(previouslyFocusedElement)
79
+
80
+ if (!hasFocusedCandidate) {
81
+ const mountEvent = new CustomEvent(AUTOFOCUS_ON_MOUNT, EVENT_OPTIONS)
82
+ container.addEventListener(AUTOFOCUS_ON_MOUNT, onMountAutoFocus)
83
+ container.dispatchEvent(mountEvent)
84
+ if (!mountEvent.defaultPrevented) {
85
+ focusFirst(removeLinks(getTabbableCandidates(container)), { select: true })
86
+ if (document.activeElement === previouslyFocusedElement) {
87
+ focus(container)
88
+ }
89
+ }
90
+ }
91
+
92
+ return () => {
93
+ container.removeEventListener(AUTOFOCUS_ON_MOUNT, onMountAutoFocus)
94
+
95
+ // We hit a react bug (fixed in v17) with focusing in unmount.
96
+ // We need to delay the focus a little to get around it for now.
97
+ // See: https://github.com/facebook/react/issues/17894
98
+ setTimeout(() => {
99
+ const unmountEvent = new CustomEvent(AUTOFOCUS_ON_UNMOUNT, EVENT_OPTIONS)
100
+ container.addEventListener(AUTOFOCUS_ON_UNMOUNT, onUnmountAutoFocus)
101
+ container.dispatchEvent(unmountEvent)
102
+ if (!unmountEvent.defaultPrevented) {
103
+ focus(previouslyFocusedElement ?? document.body, { select: true })
104
+ }
105
+ // we need to remove the listener after we `dispatchEvent`
106
+ container.removeEventListener(AUTOFOCUS_ON_UNMOUNT, onUnmountAutoFocus)
107
+
108
+ focusScopesStack.remove(focusScope)
109
+ }, 0)
110
+ }
111
+ }
112
+ }, [container, onMountAutoFocus, onUnmountAutoFocus, focusScope])
113
+
114
+ // Takes care of looping focus (when tabbing whilst at the edges)
115
+ const handleKeyDown = React.useCallback(
116
+ (event: React.KeyboardEvent) => {
117
+ if (!loop && !trapped) return
118
+ if (focusScope.paused) return
119
+
120
+ const isTabKey = event.key === 'Tab' && !event.altKey && !event.ctrlKey && !event.metaKey
121
+ const focusedElement = document.activeElement as HTMLElement | null
122
+
123
+ if (isTabKey && focusedElement) {
124
+ const container = event.currentTarget as HTMLElement
125
+ const [first, last] = getTabbableEdges(container)
126
+ const hasTabbableElementsInside = first && last
127
+
128
+ // we can only wrap focus if we have tabbable edges
129
+ if (!hasTabbableElementsInside) {
130
+ if (focusedElement === container) event.preventDefault()
131
+ } else {
132
+ if (!event.shiftKey && focusedElement === last) {
133
+ event.preventDefault()
134
+ if (loop) focus(first, { select: true })
135
+ } else if (event.shiftKey && focusedElement === first) {
136
+ event.preventDefault()
137
+ if (loop) focus(last, { select: true })
138
+ }
139
+ }
140
+ }
141
+ },
142
+ [loop, trapped, focusScope.paused]
143
+ )
144
+
145
+ const child = React.Children.only(props.children)
146
+
147
+ return React.cloneElement(child as any, {
148
+ tabIndex: -1,
149
+ ...scopeProps,
150
+ ref: composedRefs,
151
+ onKeyDown: handleKeyDown,
152
+ })
153
+ })
154
+
155
+ FocusScope.displayName = FOCUS_SCOPE_NAME
156
+
157
+ /* -------------------------------------------------------------------------------------------------
158
+ * Utils
159
+ * -----------------------------------------------------------------------------------------------*/
160
+
161
+ /**
162
+ * Attempts focusing the first element in a list of candidates.
163
+ * Stops when focus has actually moved.
164
+ */
165
+ function focusFirst(candidates: HTMLElement[], { select = false } = {}) {
166
+ const previouslyFocusedElement = document.activeElement
167
+ for (const candidate of candidates) {
168
+ focus(candidate, { select })
169
+ if (document.activeElement !== previouslyFocusedElement) return
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Returns the first and last tabbable elements inside a container.
175
+ */
176
+ function getTabbableEdges(container: HTMLElement) {
177
+ const candidates = getTabbableCandidates(container)
178
+ const first = findVisible(candidates, container)
179
+ const last = findVisible(candidates.reverse(), container)
180
+ return [first, last] as const
181
+ }
182
+
183
+ /**
184
+ * Returns a list of potential tabbable candidates.
185
+ *
186
+ * NOTE: This is only a close approximation. For example it doesn't take into account cases like when
187
+ * elements are not visible. This cannot be worked out easily by just reading a property, but rather
188
+ * necessitate runtime knowledge (computed styles, etc). We deal with these cases separately.
189
+ *
190
+ * See: https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker
191
+ * Credit: https://github.com/discord/focus-layers/blob/master/src/util/wrapFocus.tsx#L1
192
+ */
193
+ function getTabbableCandidates(container: HTMLElement) {
194
+ const nodes: HTMLElement[] = []
195
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
196
+ acceptNode: (node: any) => {
197
+ const isHiddenInput = node.tagName === 'INPUT' && node.type === 'hidden'
198
+ if (node.disabled || node.hidden || isHiddenInput) return NodeFilter.FILTER_SKIP
199
+ // `.tabIndex` is not the same as the `tabindex` attribute. It works on the
200
+ // runtime's understanding of tabbability, so this automatically accounts
201
+ // for any kind of element that could be tabbed to.
202
+ return node.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP
203
+ },
204
+ })
205
+ while (walker.nextNode()) nodes.push(walker.currentNode as HTMLElement)
206
+ // we do not take into account the order of nodes with positive `tabIndex` as it
207
+ // hinders accessibility to have tab order different from visual order.
208
+ return nodes
209
+ }
210
+
211
+ /**
212
+ * Returns the first visible element in a list.
213
+ * NOTE: Only checks visibility up to the `container`.
214
+ */
215
+ function findVisible(elements: HTMLElement[], container: HTMLElement) {
216
+ for (const element of elements) {
217
+ // we stop checking if it's hidden at the `container` level (excluding)
218
+ if (!isHidden(element, { upTo: container })) return element
219
+ }
220
+ }
221
+
222
+ function isHidden(node: HTMLElement, { upTo }: { upTo?: HTMLElement }) {
223
+ if (getComputedStyle(node).visibility === 'hidden') return true
224
+ while (node) {
225
+ // we stop at `upTo` (excluding it)
226
+ if (upTo !== undefined && node === upTo) return false
227
+ if (getComputedStyle(node).display === 'none') return true
228
+ node = node.parentElement as HTMLElement
229
+ }
230
+ return false
231
+ }
232
+
233
+ function isSelectableInput(element: any): element is FocusableTarget & { select: () => void } {
234
+ return element instanceof HTMLInputElement && 'select' in element
235
+ }
236
+
237
+ function focus(element?: FocusableTarget | null, { select = false } = {}) {
238
+ // only focus if that element is focusable
239
+ if (element && element.focus) {
240
+ const previouslyFocusedElement = document.activeElement
241
+ // NOTE: we prevent scrolling on focus, to minimize jarring transitions for users
242
+ element.focus({ preventScroll: true })
243
+ // only select if its not the same element, it supports selection and we need to select
244
+ if (element !== previouslyFocusedElement && isSelectableInput(element) && select)
245
+ element.select()
246
+ }
247
+ }
248
+
249
+ /* -------------------------------------------------------------------------------------------------
250
+ * FocusScope stack
251
+ * -----------------------------------------------------------------------------------------------*/
252
+
253
+ type FocusScopeAPI = { paused: boolean; pause(): void; resume(): void }
254
+ const focusScopesStack = createFocusScopesStack()
255
+
256
+ function createFocusScopesStack() {
257
+ /** A stack of focus scopes, with the active one at the top */
258
+ let stack: FocusScopeAPI[] = []
259
+
260
+ return {
261
+ add(focusScope: FocusScopeAPI) {
262
+ // pause the currently active focus scope (at the top of the stack)
263
+ const activeFocusScope = stack[0]
264
+ if (focusScope !== activeFocusScope) {
265
+ activeFocusScope?.pause()
266
+ }
267
+ // remove in case it already exists (because we'll re-add it at the top of the stack)
268
+ stack = arrayRemove(stack, focusScope)
269
+ stack.unshift(focusScope)
270
+ },
271
+
272
+ remove(focusScope: FocusScopeAPI) {
273
+ stack = arrayRemove(stack, focusScope)
274
+ stack[0]?.resume()
275
+ },
276
+ }
277
+ }
278
+
279
+ function arrayRemove<T>(array: T[], item: T) {
280
+ const updatedArray = [...array]
281
+ const index = updatedArray.indexOf(item)
282
+ if (index !== -1) {
283
+ updatedArray.splice(index, 1)
284
+ }
285
+ return updatedArray
286
+ }
287
+
288
+ function removeLinks(items: HTMLElement[]) {
289
+ return items.filter((item) => item.tagName !== 'A')
290
+ }
291
+
292
+ export { FocusScope }
293
+
294
+ export type { FocusScopeProps }
@@ -0,0 +1,31 @@
1
+ import React from 'react'
2
+
3
+ export interface FocusScopeProps {
4
+ /**
5
+ * When `true`, tabbing from last item will focus first tabbable
6
+ * and shift+tab from first item will focus last tababble.
7
+ * @defaultValue false
8
+ */
9
+ loop?: boolean
10
+
11
+ /**
12
+ * When `true`, focus cannot escape the focus scope via keyboard,
13
+ * pointer, or a programmatic focus.
14
+ * @defaultValue false
15
+ */
16
+ trapped?: boolean
17
+
18
+ /**
19
+ * Event handler called when auto-focusing on mount.
20
+ * Can be prevented.
21
+ */
22
+ onMountAutoFocus?: (event: Event) => void
23
+
24
+ /**
25
+ * Event handler called when auto-focusing on unmount.
26
+ * Can be prevented.
27
+ */
28
+ onUnmountAutoFocus?: (event: Event) => void
29
+
30
+ children?: React.ReactNode
31
+ }
package/src/index.tsx ADDED
@@ -0,0 +1 @@
1
+ export * from './FocusScope'
@@ -0,0 +1,6 @@
1
+ import * as React from 'react';
2
+ import { FocusScopeProps } from './FocusScopeProps';
3
+ declare const FocusScope: React.ForwardRefExoticComponent<FocusScopeProps & React.RefAttributes<HTMLDivElement>>;
4
+ export { FocusScope };
5
+ export type { FocusScopeProps };
6
+ //# sourceMappingURL=FocusScope.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FocusScope.d.ts","sourceRoot":"","sources":["../src/FocusScope.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAE9B,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA;AAgBnD,QAAA,MAAM,UAAU,wFAoId,CAAA;AA2IF,OAAO,EAAE,UAAU,EAAE,CAAA;AAErB,YAAY,EAAE,eAAe,EAAE,CAAA"}
@@ -0,0 +1,4 @@
1
+ /// <reference types="react" />
2
+ import { FocusScopeProps } from './FocusScopeProps';
3
+ export declare const FocusScope: import("react").ForwardRefExoticComponent<FocusScopeProps & import("react").RefAttributes<unknown>>;
4
+ //# sourceMappingURL=FocusScope.native.d.ts.map
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ export interface FocusScopeProps {
3
+ loop?: boolean;
4
+ trapped?: boolean;
5
+ onMountAutoFocus?: (event: Event) => void;
6
+ onUnmountAutoFocus?: (event: Event) => void;
7
+ children?: React.ReactNode;
8
+ }
9
+ //# sourceMappingURL=FocusScopeProps.d.ts.map
@@ -0,0 +1,2 @@
1
+ export * from './FocusScope';
2
+ //# sourceMappingURL=index.d.ts.map