diff --git a/src/components/Popup/Popup.scss b/src/components/Popup/Popup.scss index 246df759c5..271ff0a074 100644 --- a/src/components/Popup/Popup.scss +++ b/src/components/Popup/Popup.scss @@ -17,6 +17,7 @@ $transition-distance: 10px; z-index: 1000; visibility: hidden; + outline: none; &_open, &_exit_active { diff --git a/src/components/Popup/Popup.tsx b/src/components/Popup/Popup.tsx index c3ce44aec5..560e6cfb8f 100644 --- a/src/components/Popup/Popup.tsx +++ b/src/components/Popup/Popup.tsx @@ -134,6 +134,7 @@ export function Popup({ {...attributes.popper} {...containerProps} className={b({open}, className)} + tabIndex={-1} data-qa={qa} id={id} role={role} diff --git a/src/components/Select/README.md b/src/components/Select/README.md index 7885a81ec0..4167ceb835 100644 --- a/src/components/Select/README.md +++ b/src/components/Select/README.md @@ -30,6 +30,8 @@ | filterable | `boolean` | `false` | Indicates that select popup have filter section | | disabled | `boolean` | `false` | Indicates that the user cannot interact with the control | | hasClear | `boolean` | `false` | Enable displaying icon for clear selected options | +| onFocus | `function` | `-` | Handler that is called when the element receives focus. | +| onBlur | `function` | `-` | Handler that is called when the element loses focus. | --- diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index 783aa8a974..b73f722eed 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -4,8 +4,8 @@ import type {List} from '../List'; import {KeyCode} from '../constants'; import {useMobile} from '../mobile'; import type {CnMods} from '../utils/cn'; +import {useFocusWithin} from '../utils/interactions'; import {useForkRef} from '../utils/useForkRef'; -import {useOnFocusOutside} from '../utils/useOnFocusOutside'; import {useSelect} from '../utils/useSelect'; import {EmptyOptions, SelectControl, SelectFilter, SelectList, SelectPopup} from './components'; @@ -201,14 +201,23 @@ export const Select = React.forwardRef(function } const handleClose = React.useCallback(() => toggleOpen(false), [toggleOpen]); - const {onFocus, onBlur} = useOnFocusOutside({enabled: open, onFocusOutside: handleClose}); + const {onFocus, onBlur} = props; + const {focusWithinProps} = useFocusWithin({ + onFocusWithin: onFocus, + onBlurWithin: React.useCallback( + (e: React.FocusEvent) => { + onBlur?.(e); + handleClose(); + }, + [handleClose, onBlur], + ), + }); return (
; -export const SelectControl = React.forwardRef((props, ref) => { +export const SelectControl = React.forwardRef((props, ref) => { const { toggleOpen, clearValue, @@ -57,8 +56,6 @@ export const SelectControl = React.forwardRef((props, value, hasClear, } = props; - const controlRef = React.useRef(null); - const handleControlRef = useForkRef(ref, controlRef); const showOptionsText = Boolean(selectedOptionsContent); const showPlaceholder = Boolean(placeholder && !showOptionsText); const hasValue = Array.isArray(value) && !isEmpty(value.filter(Boolean)); @@ -118,7 +115,7 @@ export const SelectControl = React.forwardRef((props, { onKeyDown, onClick: toggleOpen, - ref: handleControlRef, + ref, open: Boolean(open), renderClear: (arg) => renderClearIcon(arg), }, @@ -128,8 +125,9 @@ export const SelectControl = React.forwardRef((props, return ( -
+
+ ); + }; + + render(); + + const button = screen.getByTestId('btn-1'); + + await user.tab(); + expect(button).toHaveFocus(); + + await user.tab(); + expect(screen.getByTestId('btn-2')).toHaveFocus(); + + expect(events).toEqual([ + {type: 'focus', target: button}, + {type: 'focuschange', isFocused: true}, + {type: 'blur', target: button}, + {type: 'focuschange', isFocused: false}, + ]); + }); + + it('should handle focus events on children', async () => { + const events: any[] = []; + const Component = () => { + const {focusWithinProps} = useFocusWithin({ + onFocusWithin: (e) => events.push({type: e.type, target: e.target}), + onBlurWithin: (e) => events.push({type: e.type, target: e.target}), + onFocusWithinChange: (isFocused) => events.push({type: 'focuschange', isFocused}), + }); + + return ( +
+
+ ); + }; + + render(); + + const el = screen.getByTestId('component'); + const child = screen.getByRole('button'); + + act(() => { + child.focus(); + }); + + el.focus(); + child.focus(); + + act(() => { + child.blur(); + }); + + expect(events).toEqual([ + {type: 'focus', target: child}, + {type: 'focuschange', isFocused: true}, + {type: 'blur', target: child}, + {type: 'focuschange', isFocused: false}, + ]); + }); + + it('should handle focus events on children within portal', async () => { + const events: any[] = []; + const Component = () => { + const {focusWithinProps} = useFocusWithin({ + onFocusWithin: (e) => events.push({type: e.type, target: e.target}), + onBlurWithin: (e) => events.push({type: e.type, target: e.target}), + onFocusWithinChange: (isFocused) => events.push({type: 'focuschange', isFocused}), + }); + + return ( +
+ {ReactDom.createPortal(
+ ); + }; + + render(); + + const el = screen.getByTestId('component'); + const child = screen.getByRole('button'); + + act(() => { + child.focus(); + }); + + el.focus(); + child.focus(); + + act(() => { + child.blur(); + }); + + expect(events).toEqual([ + {type: 'focus', target: child}, + {type: 'focuschange', isFocused: true}, + {type: 'blur', target: child}, + {type: 'focuschange', isFocused: false}, + ]); + }); + + it('should fire onBlur when a focused element is disabled', async () => { + const user = userEvent.setup(); + const onFocus = jest.fn(); + const onBlur = jest.fn(); + + function Component(props: {disabled?: boolean}) { + const {focusWithinProps} = useFocusWithin({ + onFocusWithin: onFocus, + onBlurWithin: onBlur, + }); + return ( +
+
+ ); + } + + const {rerender} = render(); + + await user.tab(); + expect(screen.getByTestId('btn-1')).toHaveFocus(); + await user.tab(); + expect(screen.getByTestId('btn-2')).toHaveFocus(); + + expect(onFocus).toHaveBeenCalledTimes(1); + expect(onBlur).not.toHaveBeenCalled(); + + rerender(); + + await waitFor(() => expect(onBlur).toHaveBeenCalledTimes(1)); + expect(onFocus).toHaveBeenCalledTimes(1); + }); + + it('should not handle focus events if disabled', () => { + const events: any[] = []; + const Component = () => { + const {focusWithinProps} = useFocusWithin({ + isDisabled: true, + onFocusWithin: (e) => events.push({type: e.type, target: e.target}), + onBlurWithin: (e) => events.push({type: e.type, target: e.target}), + onFocusWithinChange: (isFocused) => events.push({type: 'focuschange', isFocused}), + }); + + return + * + * ... + * + * + * ); + * } + * } + */ +export function useFocusWithin(props: FocusWithinProps) { + const {onFocusWithin, onBlurWithin, onFocusWithinChange, isDisabled} = props; + + const isFocusWithinRef = React.useRef(false); + + const onFocus = React.useCallback( + (event: React.FocusEvent) => { + if (!isFocusWithinRef.current && document.activeElement === event.target) { + isFocusWithinRef.current = true; + + if (onFocusWithin) { + onFocusWithin(event); + } + + if (onFocusWithinChange) { + onFocusWithinChange(true); + } + } + }, + [onFocusWithin, onFocusWithinChange], + ); + + const onBlur = React.useCallback( + (event: React.FocusEvent) => { + if (!isFocusWithinRef.current) { + return; + } + + isFocusWithinRef.current = false; + + if (onBlurWithin) { + onBlurWithin(event); + } + + if (onFocusWithinChange) { + onFocusWithinChange(false); + } + }, + [onBlurWithin, onFocusWithinChange], + ); + + const {onBlur: onBlurHandler, onFocus: onFocusHandler} = useFocusEvents({ + onFocus, + onBlur, + isDisabled, + }); + + if (isDisabled) { + return { + focusWithinProps: { + onFocus: undefined, + onBlur: undefined, + }, + }; + } + + return { + focusWithinProps: { + onFocus: onFocusHandler, + onBlur: onBlurHandler, + }, + }; +} + +function useFocusEvents({ + onFocus, + onBlur, + isDisabled, +}: { + onFocus: (event: React.FocusEvent) => void; + onBlur: (event: React.FocusEvent) => void; + isDisabled?: boolean; +}) { + const capturedRef = React.useRef(false); + const targetRef = React.useRef(null); + + React.useEffect(() => { + if (isDisabled) { + return undefined; + } + + const handleFocus = function () { + capturedRef.current = false; + }; + + const handleFocusIn = function (event: FocusEvent) { + if (!capturedRef.current && targetRef.current) { + const blurEvent = new FocusEvent('blur', { + ...event, + relatedTarget: event.target, + bubbles: false, + cancelable: false, + }); + onBlur( + new SyntheticFocusEvent('blur', blurEvent, { + target: targetRef.current, + currentTarget: targetRef.current, + }), + ); + targetRef.current = null; + } + }; + + window.addEventListener('focus', handleFocus, {capture: true}); + // use focusin because a focus event does not bubble and current browser + // implementations fire focusin events after fucus event + window.addEventListener('focusin', handleFocusIn); + + return () => { + window.removeEventListener('focus', handleFocus, {capture: true}); + window.removeEventListener('focusin', handleFocusIn); + }; + }, [isDisabled, onBlur]); + + const onBlurHandler = React.useCallback( + (event: React.FocusEvent) => { + if ( + event.relatedTarget === null || + event.relatedTarget === document.body || + event.relatedTarget === (document as EventTarget) + ) { + onBlur(event); + targetRef.current = null; + } + }, + [onBlur], + ); + + const onSyntheticFocus = useSyntheticBlurEvent(onBlur); + + const onFocusHandler = React.useCallback( + (event: React.FocusEvent) => { + capturedRef.current = true; + targetRef.current = event.target; + onSyntheticFocus(event); + onFocus(event); + }, + [onSyntheticFocus, onFocus], + ); + + return {onBlur: onBlurHandler, onFocus: onFocusHandler}; +} diff --git a/src/components/utils/interactions/useSyntheticBlurEvent.tsx b/src/components/utils/interactions/useSyntheticBlurEvent.tsx new file mode 100644 index 0000000000..10628e27e2 --- /dev/null +++ b/src/components/utils/interactions/useSyntheticBlurEvent.tsx @@ -0,0 +1,75 @@ +import React from 'react'; + +import {SyntheticFocusEvent} from './SyntheticFocusEvent'; + +interface State { + isFocused: boolean; + observer: MutationObserver | null; +} + +// React does not fire onBlur when an element is disabled https://github.com/facebook/react/issues/9142 +export function useSyntheticBlurEvent(onBlur?: React.FocusEventHandler) { + const stateRef = React.useRef({ + isFocused: false, + observer: null, + }); + + React.useEffect(() => { + const state = stateRef.current; + return () => { + if (state.observer) { + state.observer.disconnect(); + state.observer = null; + } + }; + }, []); + + const handleFocus = React.useCallback( + (event: React.FocusEvent) => { + const target = event.target; + if ( + target instanceof HTMLButtonElement || + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement + ) { + stateRef.current.isFocused = true; + + const handleBlur = (e: FocusEvent) => { + stateRef.current.isFocused = false; + + if (target.disabled) { + onBlur?.(new SyntheticFocusEvent('blur', e)); + } + + if (stateRef.current.observer) { + stateRef.current.observer.disconnect(); + stateRef.current.observer = null; + } + }; + + // TS can't resolve correct definition for addEventListener when target is union type + // @ts-expect-error + target.addEventListener('focusout', handleBlur, {once: true}); + + const observer = new MutationObserver(() => { + if (stateRef.current.isFocused && target.disabled) { + observer.disconnect(); + stateRef.current.observer = null; + const relatedTarget = + target === document.activeElement ? null : document.activeElement; + target.dispatchEvent(new FocusEvent('blur', {relatedTarget})); + target.dispatchEvent( + new FocusEvent('focusout', {relatedTarget, bubbles: true}), + ); + } + }); + observer.observe(target, {attributes: true, attributeFilter: ['disabled']}); + stateRef.current.observer = observer; + } + }, + [onBlur], + ); + + return handleFocus; +} diff --git a/src/components/utils/useOnFocusOutside.ts b/src/components/utils/useOnFocusOutside.ts index 75804f74de..9c421c36dd 100644 --- a/src/components/utils/useOnFocusOutside.ts +++ b/src/components/utils/useOnFocusOutside.ts @@ -13,6 +13,8 @@ interface UseOnFocusOutsideProps { } /** + * @deprecated use useFocusWithin instead + * * Calls callback on focus element outside of some React sub-tree * * @param {Object} props