diff --git a/CHANGELOG.md b/CHANGELOG.md index 56106e2945..652b339ceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Restricted prop set in the `Button`, `Avatar`, `Box` and `Image` styles; changed `avatarBorderWidth` and `statusBorderWidth` avatar variables types from number to string and updated styles in Teams theme @mnajdova ([#2238](https://github.com/microsoft/fluent-ui-react/pull/2238)) - Restricted prop set in the `List` & `ListItem` @layershifter ([#2238](https://github.com/microsoft/fluent-ui-react/pull/2238)) - Remove `mountDocument` prop in `Popup` & `MenuButton` components @layershifter ([#2286](https://github.com/microsoft/fluent-ui-react/pull/2286)) +- Remove `toRefObject` function @layershifter ([#2287](https://github.com/microsoft/fluent-ui-react/pull/2287)) ### Fixes - Fix styleParam to always be required in the styles functions @layershifter, @mnajdova ([#2235](https://github.com/microsoft/fluent-ui-react/pull/2235)) @@ -42,6 +43,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Add `useStyles()` hook to use theming capabilities in custom components @layershifter, @mnajdova ([#2217](https://github.com/microsoft/fluent-ui-react/pull/2217)) - Add optional wrapper function to `List` which can be used to inject custom scrollbars to `Dropdown` @jurokapsiar ([#2092](https://github.com/microsoft/fluent-ui-react/pull/2092)) - Add `useTelemetry()` hook for adding telemetry information for the Fluent components and improve return types for the `useStyles` and `useStateManager` hooks @mnajdova ([#2257](https://github.com/microsoft/fluent-ui-react/pull/2257)) +- Add `target` prop to `EventListener` component and `useEventListener()` hook @layershifter ([#2287](https://github.com/microsoft/fluent-ui-react/pull/2287)) ### Documentation - Add per-component performance charts @miroslavstastny ([#2240](https://github.com/microsoft/fluent-ui-react/pull/2240)) diff --git a/docs/src/prototypes/EditorToolbar/EditorToolbar.tsx b/docs/src/prototypes/EditorToolbar/EditorToolbar.tsx index fa3722e676..af78414f3c 100644 --- a/docs/src/prototypes/EditorToolbar/EditorToolbar.tsx +++ b/docs/src/prototypes/EditorToolbar/EditorToolbar.tsx @@ -16,7 +16,6 @@ import { ToolbarMenuItemShorthandKinds, } from '@fluentui/react' import { useEventListener } from '@fluentui/react-component-event-listener' -import { toRefObject } from '@fluentui/react-component-ref' import * as keyboardKey from 'keyboard-key' import * as _ from 'lodash' import * as React from 'react' @@ -243,7 +242,7 @@ const EditorToolbar: React.FC = props => { } }, type: 'keydown', - targetRef: toRefObject(props.target), + target: props.target, }) useEventListener({ listener: () => { @@ -258,7 +257,7 @@ const EditorToolbar: React.FC = props => { } }, type: 'resize', - targetRef: toRefObject(props.target.defaultView), + target: props.target.defaultView, }) return ( diff --git a/packages/react-component-event-listener/src/EventListener.ts b/packages/react-component-event-listener/src/EventListener.ts index 48b318b092..b7a3dcb06e 100644 --- a/packages/react-component-event-listener/src/EventListener.ts +++ b/packages/react-component-event-listener/src/EventListener.ts @@ -1,7 +1,7 @@ import * as PropTypes from 'prop-types' import useEventListener from './useEventListener' -import { EventListenerOptions, EventTypes, TargetRef } from './types' +import { EventListenerOptions, EventTypes, Target, TargetRef } from './types' function EventListener(props: EventListenerOptions) { useEventListener(props) @@ -16,9 +16,10 @@ EventListener.propTypes = ? { capture: PropTypes.bool, listener: PropTypes.func.isRequired, + target: PropTypes.object as PropTypes.Validator, targetRef: PropTypes.shape({ current: PropTypes.object, - }).isRequired as PropTypes.Validator, + }) as PropTypes.Validator, type: PropTypes.string.isRequired as PropTypes.Validator, } : {} diff --git a/packages/react-component-event-listener/src/types.ts b/packages/react-component-event-listener/src/types.ts index 4155976c11..57c9a0d433 100644 --- a/packages/react-component-event-listener/src/types.ts +++ b/packages/react-component-event-listener/src/types.ts @@ -7,8 +7,11 @@ export interface EventListenerOptions { /** A function which receives a notification when an event of the specified type occurs. */ listener: EventHandler + /** A target node. Use `target` or `targetRef` prop. */ + target?: Target + /** A ref object with a target node. */ - targetRef: TargetRef + targetRef?: TargetRef /** A case-sensitive string representing the event type to listen for. */ type: T @@ -17,4 +20,5 @@ export interface EventListenerOptions { export type EventHandler = (e: DocumentEventMap[T]) => void export type EventTypes = keyof DocumentEventMap -export type TargetRef = React.RefObject +export type Target = Node | Window +export type TargetRef = React.RefObject diff --git a/packages/react-component-event-listener/src/useEventListener.ts b/packages/react-component-event-listener/src/useEventListener.ts index 91f7561c58..125633ee3c 100644 --- a/packages/react-component-event-listener/src/useEventListener.ts +++ b/packages/react-component-event-listener/src/useEventListener.ts @@ -1,14 +1,14 @@ import * as React from 'react' -import { EventHandler, EventListenerOptions, EventTypes, TargetRef } from './types' +import { EventHandler, EventListenerOptions, EventTypes, Target } from './types' const isActionSupported = ( - targetRef: TargetRef, + element: Target | null | undefined, method: 'addEventListener' | 'removeEventListener', -) => targetRef && !!targetRef.current && !!targetRef.current[method] +): element is Target => (element ? !!element[method] : false) const useEventListener = (options: EventListenerOptions): void => { - const { capture, listener, type, targetRef } = options + const { capture, listener, type, target, targetRef } = options const latestListener = React.useRef>(listener) latestListener.current = listener @@ -17,25 +17,44 @@ const useEventListener = (options: EventListenerOptions return latestListener.current(event) }, []) + if (process.env.NODE_ENV !== 'production') { + React.useEffect(() => { + if (typeof target !== 'undefined' && typeof targetRef !== 'undefined') { + throw new Error( + '`target` and `targetRef` props are mutually exclusive, please use one of them.', + ) + } + + if (typeof target === 'undefined' && typeof targetRef === 'undefined') { + throw new Error( + "`target` and `targetRef` props are `undefined`, it' required to use one of them.", + ) + } + }, [target, targetRef]) + } + React.useEffect(() => { - if (isActionSupported(targetRef, 'addEventListener')) { - ;(targetRef.current as NonNullable).addEventListener(type, eventHandler, capture) + const element: Target | null | undefined = + typeof targetRef === 'undefined' ? target : targetRef.current + + if (isActionSupported(element, 'addEventListener')) { + element.addEventListener(type, eventHandler, capture) } else if (process.env.NODE_ENV !== 'production') { throw new Error( - '@fluentui/react-component-event-listener: Passed `targetRef` is not valid or does not support `addEventListener()` method.', + '@fluentui/react-component-event-listener: Passed `element` is not valid or does not support `addEventListener()` method.', ) } return () => { - if (isActionSupported(targetRef, 'removeEventListener')) { - ;(targetRef.current as NonNullable).removeEventListener(type, eventHandler, capture) + if (isActionSupported(element, 'removeEventListener')) { + element.removeEventListener(type, eventHandler, capture) } else if (process.env.NODE_ENV !== 'production') { throw new Error( - '@fluentui/react-component-event-listener: Passed `targetRef` is not valid or does not support `removeEventListener()` method.', + '@fluentui/react-component-event-listener: Passed `element` is not valid or does not support `removeEventListener()` method.', ) } } - }, [capture, targetRef, type]) + }, [capture, target, targetRef, type]) } export default useEventListener diff --git a/packages/react-component-event-listener/test/EventListener-test.tsx b/packages/react-component-event-listener/test/EventListener-test.tsx index 57280e2a0b..56e46fd886 100644 --- a/packages/react-component-event-listener/test/EventListener-test.tsx +++ b/packages/react-component-event-listener/test/EventListener-test.tsx @@ -4,6 +4,23 @@ import * as React from 'react' // @ts-ignore import * as simulant from 'simulant' +class TestBoundary extends React.Component<{ onError: (e: Error) => void }, { hasError: boolean }> { + state = { hasError: false } + + componentDidCatch(error: Error) { + this.props.onError(error) + this.setState({ hasError: true }) + } + + render() { + if (this.state.hasError) { + return null + } + + return this.props.children + } +} + describe('EventListener', () => { describe('listener', () => { it('handles events on `target`', () => { @@ -83,4 +100,70 @@ describe('EventListener', () => { expect(listener).toHaveBeenCalledTimes(2) }) }) + + describe('target', () => { + it('handles events', () => { + const listener = jest.fn() + mount() + + simulant.fire(document, 'click') + expect(listener).toHaveBeenCalledTimes(1) + }) + + it('throws an error when is used with `targetRef`', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + const onError = jest.fn() + + mount( + + + , + ) + + expect(onError).toBeCalledWith( + expect.objectContaining({ + message: '`target` and `targetRef` props are mutually exclusive, please use one of them.', + }), + ) + + // We need to clean up mocks to avoid errors reported by React + ;(console.error as any).mockClear() + }) + + it('throws an error when not defined', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + const onError = jest.fn() + + mount( + + + , + ) + + expect(onError).toBeCalledWith( + expect.objectContaining({ + message: + "`target` and `targetRef` props are `undefined`, it' required to use one of them.", + }), + ) + + // We need to clean up mocks to avoid errors reported by React + ;(console.error as any).mockClear() + }) + }) + + describe('targetRef', () => { + it('handles events', () => { + const listener = jest.fn() + mount() + + simulant.fire(document, 'click') + expect(listener).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/packages/react-component-ref/src/index.ts b/packages/react-component-ref/src/index.ts index d5724dd37d..5242fee943 100644 --- a/packages/react-component-ref/src/index.ts +++ b/packages/react-component-ref/src/index.ts @@ -1,6 +1,5 @@ export { default as handleRef } from './handleRef' export { default as isRefObject } from './isRefObject' -export { default as toRefObject } from './toRefObject' export { default as Ref } from './Ref' export { default as RefFindNode } from './RefFindNode' diff --git a/packages/react-component-ref/src/toRefObject.ts b/packages/react-component-ref/src/toRefObject.ts deleted file mode 100644 index 872620d464..0000000000 --- a/packages/react-component-ref/src/toRefObject.ts +++ /dev/null @@ -1,25 +0,0 @@ -import * as React from 'react' - -const nullRefObject: React.RefObject = { current: null } - -// A map of created ref objects to provide memoization. -const refObjects = new WeakMap>() - -/** Creates a React ref object from existing DOM node. */ -const toRefObject = (node: T): React.RefObject => { - // A "null" is not valid key for a WeakMap - if (node === null) { - return nullRefObject as any - } - - if (refObjects.has(node)) { - return refObjects.get(node) as React.RefObject - } - - const refObject: React.RefObject = { current: node } - refObjects.set(node, refObject) - - return refObject -} - -export default toRefObject diff --git a/packages/react-component-ref/test/toRefObject-test.ts b/packages/react-component-ref/test/toRefObject-test.ts deleted file mode 100644 index d103efe878..0000000000 --- a/packages/react-component-ref/test/toRefObject-test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { toRefObject } from '@fluentui/react-component-ref' - -describe('toRefObject', () => { - it('creates an ref object from an input', () => { - const node = document.createElement('div') - expect(toRefObject(node)).toHaveProperty('current', node) - }) - - it('handles "null" as input', () => { - expect(toRefObject(null as any)).toHaveProperty('current', null) - }) - - it('returned object is memoized', () => { - const node = document.createElement('div') - const refObject = toRefObject(node) - - expect(toRefObject(node)).toBe(refObject) - }) -}) diff --git a/packages/react/src/components/Debug/Debug.tsx b/packages/react/src/components/Debug/Debug.tsx index 6c0c3bd3e5..931268e9c9 100644 --- a/packages/react/src/components/Debug/Debug.tsx +++ b/packages/react/src/components/Debug/Debug.tsx @@ -1,7 +1,6 @@ import keyboardKey from 'keyboard-key' import * as PropTypes from 'prop-types' import * as React from 'react' -import { toRefObject } from '@fluentui/react-component-ref' import { EventListener } from '@fluentui/react-component-event-listener' import { isBrowser } from '../../utils' @@ -146,22 +145,18 @@ class Debug extends React.Component { if (process.env.NODE_ENV !== 'production' && isDebugEnabled) { return ( <> - + {isSelecting && ( )} {isSelecting && fiberNav && fiberNav.domNode && ( )} diff --git a/packages/react/src/components/Dialog/Dialog.tsx b/packages/react/src/components/Dialog/Dialog.tsx index 0c20b1ceed..6485a452a7 100644 --- a/packages/react/src/components/Dialog/Dialog.tsx +++ b/packages/react/src/components/Dialog/Dialog.tsx @@ -2,7 +2,7 @@ import { Accessibility, dialogBehavior } from '@fluentui/accessibility' import { FocusTrapZoneProps } from '@fluentui/react-bindings' import { Unstable_NestingAuto } from '@fluentui/react-component-nesting-registry' import { EventListener } from '@fluentui/react-component-event-listener' -import { Ref, toRefObject } from '@fluentui/react-component-ref' +import { Ref } from '@fluentui/react-component-ref' import * as customPropTypes from '@fluentui/react-proptypes' import * as _ from 'lodash' import * as PropTypes from 'prop-types' @@ -330,7 +330,6 @@ class Dialog extends AutoControlledComponent, DialogStat ) - const targetRef = toRefObject(this.context.target) const triggerAccessibility: TriggerAccessibility = { attributes: accessibility.attributes.trigger, keyHandlers: accessibility.keyHandlers.trigger, @@ -366,14 +365,14 @@ class Dialog extends AutoControlledComponent, DialogStat {closeOnOutsideClick && ( )} diff --git a/packages/react/src/components/Menu/MenuItem.tsx b/packages/react/src/components/Menu/MenuItem.tsx index 0ae3ee368e..de7fc05168 100644 --- a/packages/react/src/components/Menu/MenuItem.tsx +++ b/packages/react/src/components/Menu/MenuItem.tsx @@ -1,7 +1,7 @@ import { Accessibility, menuItemBehavior, submenuBehavior } from '@fluentui/accessibility' import { focusAsync } from '@fluentui/react-bindings' import { EventListener } from '@fluentui/react-component-event-listener' -import { Ref, toRefObject } from '@fluentui/react-component-ref' +import { Ref } from '@fluentui/react-component-ref' import * as customPropTypes from '@fluentui/react-proptypes' import * as _ from 'lodash' import cx from 'classnames' @@ -215,7 +215,6 @@ class MenuItem extends AutoControlledComponent, MenuIt const defaultIndicator = { name: vertical ? 'icon-arrow-end' : 'icon-arrow-down' } const indicatorWithDefaults = indicator === undefined ? defaultIndicator : indicator - const targetRef = toRefObject(this.context.target) const menuItemInner = childrenExist(children) ? ( children @@ -274,7 +273,11 @@ class MenuItem extends AutoControlledComponent, MenuIt })} - + ) : null diff --git a/packages/react/src/components/Popup/Popup.tsx b/packages/react/src/components/Popup/Popup.tsx index 1a82c47c9a..4fca47f4b5 100644 --- a/packages/react/src/components/Popup/Popup.tsx +++ b/packages/react/src/components/Popup/Popup.tsx @@ -6,7 +6,7 @@ import { } from '@fluentui/react-bindings' import { EventListener } from '@fluentui/react-component-event-listener' import { NodeRef, Unstable_NestingAuto } from '@fluentui/react-component-nesting-registry' -import { handleRef, toRefObject, Ref } from '@fluentui/react-component-ref' +import { handleRef, Ref } from '@fluentui/react-component-ref' import * as customPropTypes from '@fluentui/react-proptypes' import * as React from 'react' import * as PropTypes from 'prop-types' @@ -477,9 +477,7 @@ export default class Popup extends AutoControlledComponent ) @@ -501,8 +499,6 @@ export default class Popup extends AutoControlledComponent ({ ...(rtl && { dir: 'rtl' }), @@ -535,19 +531,19 @@ export default class Popup extends AutoControlledComponent @@ -556,13 +552,13 @@ export default class Popup extends AutoControlledComponent diff --git a/packages/react/src/components/Portal/Portal.tsx b/packages/react/src/components/Portal/Portal.tsx index ef4998fca2..da1ac9bd7e 100644 --- a/packages/react/src/components/Portal/Portal.tsx +++ b/packages/react/src/components/Portal/Portal.tsx @@ -5,7 +5,7 @@ import { FocusTrapZoneProps, } from '@fluentui/react-bindings' import { EventListener } from '@fluentui/react-component-event-listener' -import { handleRef, Ref, toRefObject } from '@fluentui/react-component-ref' +import { handleRef, Ref } from '@fluentui/react-component-ref' import * as customPropTypes from '@fluentui/react-proptypes' import * as PropTypes from 'prop-types' import * as React from 'react' @@ -126,7 +126,6 @@ class Portal extends AutoControlledComponent { const contentToRender = childrenExist(children) ? children : content const focusTrapZoneProps = (_.keys(trapFocus).length && trapFocus) || {} - const targetRef = toRefObject(this.context.target) return ( open && ( @@ -141,7 +140,11 @@ class Portal extends AutoControlledComponent { ) : ( contentToRender )} - + ) diff --git a/packages/react/src/components/Toolbar/Toolbar.tsx b/packages/react/src/components/Toolbar/Toolbar.tsx index 0fc284db4f..8a2a849fa6 100644 --- a/packages/react/src/components/Toolbar/Toolbar.tsx +++ b/packages/react/src/components/Toolbar/Toolbar.tsx @@ -8,7 +8,7 @@ import * as React from 'react' import * as _ from 'lodash' import * as customPropTypes from '@fluentui/react-proptypes' import * as PropTypes from 'prop-types' -import { Ref, toRefObject } from '@fluentui/react-component-ref' +import { Ref } from '@fluentui/react-component-ref' import { EventListener } from '@fluentui/react-component-event-listener' import { @@ -498,8 +498,6 @@ class Toolbar extends UIComponent> { unhandledProps, rtl, }): React.ReactNode { - const windowRef = toRefObject(this.context.target.defaultView) - this.rtl = rtl const { children, items, overflow, overflowItem } = this.props @@ -530,7 +528,11 @@ class Toolbar extends UIComponent> {
- + ) } diff --git a/packages/react/src/components/Toolbar/ToolbarItem.tsx b/packages/react/src/components/Toolbar/ToolbarItem.tsx index 508852977f..f6e350de54 100644 --- a/packages/react/src/components/Toolbar/ToolbarItem.tsx +++ b/packages/react/src/components/Toolbar/ToolbarItem.tsx @@ -4,7 +4,7 @@ import * as PropTypes from 'prop-types' import * as customPropTypes from '@fluentui/react-proptypes' import { Accessibility, toolbarItemBehavior } from '@fluentui/accessibility' import cx from 'classnames' -import { Ref, toRefObject } from '@fluentui/react-component-ref' +import { Ref } from '@fluentui/react-component-ref' import { EventListener } from '@fluentui/react-component-event-listener' import { @@ -199,7 +199,6 @@ class ToolbarItem extends UIComponent> { renderComponent({ ElementType, classes, unhandledProps, accessibility, variables }) { const { icon, children, disabled, popup, menu, menuOpen, wrapper } = this.props - const targetRef = toRefObject(this.context.target) const itemElement = ( > { diff --git a/packages/react/src/components/Toolbar/ToolbarMenuItem.tsx b/packages/react/src/components/Toolbar/ToolbarMenuItem.tsx index 55b5a709f0..70c19c25e9 100644 --- a/packages/react/src/components/Toolbar/ToolbarMenuItem.tsx +++ b/packages/react/src/components/Toolbar/ToolbarMenuItem.tsx @@ -5,7 +5,7 @@ import cx from 'classnames' import * as PropTypes from 'prop-types' import { EventListener } from '@fluentui/react-component-event-listener' -import { Ref, toRefObject } from '@fluentui/react-component-ref' +import { Ref } from '@fluentui/react-component-ref' import * as customPropTypes from '@fluentui/react-proptypes' import { focusAsync } from '@fluentui/react-bindings' import { GetRefs, NodeRef, Unstable_NestingAuto } from '@fluentui/react-component-nesting-registry' @@ -281,8 +281,6 @@ class ToolbarMenuItem extends AutoControlledComponent< } = this.props const { menuOpen } = this.state - const targetRef = toRefObject(this.context.target) - const elementType = ( diff --git a/packages/react/src/components/Tooltip/Tooltip.tsx b/packages/react/src/components/Tooltip/Tooltip.tsx index bdbc0adaeb..2f35194a6e 100644 --- a/packages/react/src/components/Tooltip/Tooltip.tsx +++ b/packages/react/src/components/Tooltip/Tooltip.tsx @@ -1,6 +1,6 @@ import { Accessibility, tooltipAsLabelBehavior } from '@fluentui/accessibility' import { ReactAccessibilityBehavior } from '@fluentui/react-bindings' -import { toRefObject, Ref } from '@fluentui/react-component-ref' +import { Ref } from '@fluentui/react-component-ref' import * as customPropTypes from '@fluentui/react-proptypes' import * as React from 'react' import * as PropTypes from 'prop-types' @@ -141,9 +141,9 @@ export default class Tooltip extends AutoControlledComponent + contentRef = React.createRef() pointerTargetRef = React.createRef() triggerRef = React.createRef() - contentRef = React.createRef() closeTimeoutId actionHandlers = { @@ -259,7 +259,7 @@ export default class Tooltip extends AutoControlledComponent )