From 260c09dee3930be9396d437a1df8ea27bc8bc5fd Mon Sep 17 00:00:00 2001 From: roman Date: Tue, 11 Aug 2026 13:19:50 +0200 Subject: [PATCH] refactor(flyout): migrate Flyout from Flow to TypeScript --- .../flyout/{Flyout.js => Flyout.js.flow} | 0 src/components/flyout/Flyout.stories.tsx | 1 - src/components/flyout/Flyout.tsx | 475 ++++++++++++++++++ ...FlyoutContext.js => FlyoutContext.js.flow} | 0 src/components/flyout/FlyoutContext.ts | 9 + .../flyout/{Overlay.js => Overlay.js.flow} | 0 src/components/flyout/Overlay.tsx | 51 ++ src/components/flyout/OverlayHeader.tsx | 2 - .../{Flyout.test.js => Flyout.test.tsx} | 144 +++--- .../{Overlay.test.js => Overlay.test.tsx} | 22 +- ...yHeader.test.js => OverlayHeader.test.tsx} | 0 .../flyout/{index.js => index.js.flow} | 0 src/components/flyout/index.ts | 8 + 13 files changed, 626 insertions(+), 86 deletions(-) rename src/components/flyout/{Flyout.js => Flyout.js.flow} (100%) create mode 100644 src/components/flyout/Flyout.tsx rename src/components/flyout/{FlyoutContext.js => FlyoutContext.js.flow} (100%) create mode 100644 src/components/flyout/FlyoutContext.ts rename src/components/flyout/{Overlay.js => Overlay.js.flow} (100%) create mode 100644 src/components/flyout/Overlay.tsx rename src/components/flyout/__tests__/{Flyout.test.js => Flyout.test.tsx} (89%) rename src/components/flyout/__tests__/{Overlay.test.js => Overlay.test.tsx} (77%) rename src/components/flyout/__tests__/{OverlayHeader.test.js => OverlayHeader.test.tsx} (100%) rename src/components/flyout/{index.js => index.js.flow} (100%) create mode 100644 src/components/flyout/index.ts diff --git a/src/components/flyout/Flyout.js b/src/components/flyout/Flyout.js.flow similarity index 100% rename from src/components/flyout/Flyout.js rename to src/components/flyout/Flyout.js.flow diff --git a/src/components/flyout/Flyout.stories.tsx b/src/components/flyout/Flyout.stories.tsx index e6dc8eed7f..cf28f94184 100644 --- a/src/components/flyout/Flyout.stories.tsx +++ b/src/components/flyout/Flyout.stories.tsx @@ -9,7 +9,6 @@ import PrimaryButton from '../primary-button'; // @ts-ignore JS import import TextArea from '../text-area'; -// @ts-ignore JS import import { Flyout, Overlay } from '.'; import notes from './Flyout.stories.md'; diff --git a/src/components/flyout/Flyout.tsx b/src/components/flyout/Flyout.tsx new file mode 100644 index 0000000000..8e7cbee479 --- /dev/null +++ b/src/components/flyout/Flyout.tsx @@ -0,0 +1,475 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import TetherComponent from 'react-tether'; +import uniqueId from 'lodash/uniqueId'; +import { KEYS } from '../../constants'; + +import FlyoutContext from './FlyoutContext'; + +import './Flyout.scss'; + +const BOTTOM_CENTER = 'bottom-center'; +const BOTTOM_LEFT = 'bottom-left'; +const BOTTOM_RIGHT = 'bottom-right'; +const MIDDLE_LEFT = 'middle-left'; +const MIDDLE_RIGHT = 'middle-right'; +const TOP_CENTER = 'top-center'; +const TOP_LEFT = 'top-left'; +const TOP_RIGHT = 'top-right'; + +const positions = { + [BOTTOM_CENTER]: { + attachment: 'top center', + targetAttachment: 'bottom center', + }, + [BOTTOM_LEFT]: { + attachment: 'top right', + targetAttachment: 'bottom right', + }, + [BOTTOM_RIGHT]: { + attachment: 'top left', + targetAttachment: 'bottom left', + }, + [MIDDLE_LEFT]: { + attachment: 'middle right', + targetAttachment: 'middle left', + }, + [MIDDLE_RIGHT]: { + attachment: 'middle left', + targetAttachment: 'middle right', + }, + [TOP_CENTER]: { + attachment: 'bottom center', + targetAttachment: 'top center', + }, + [TOP_LEFT]: { + attachment: 'bottom right', + targetAttachment: 'top right', + }, + [TOP_RIGHT]: { + attachment: 'bottom left', + targetAttachment: 'top left', + }, +}; + +const OVERLAY_ROLE = 'dialog'; + +/** + * Checks if there is a clickable ancestor or self + * @param {Node} rootNode The base node we should stop at + * @param {Node} targetNode The target node of the event + * @returns {boolean} + */ +const hasClickableAncestor = (rootNode: Node | null, targetNode: EventTarget | null) => { + // Check if the element or any of the ancestors are click-able (stopping at the component boundary) + let currentNode: Node | null = targetNode instanceof Node ? targetNode : null; + while (currentNode && currentNode instanceof Node && currentNode.parentNode && currentNode !== rootNode) { + const nodeName = currentNode.nodeName.toUpperCase(); + if (nodeName === 'A' || nodeName === 'BUTTON') { + return true; + } + currentNode = currentNode.parentNode; + } + return false; +}; + +/** + * Checks if the target element is inside an element with the given CSS class. + * @param {HTMLElement} targetEl The target element + * @param {string} className A CSS class on the element to check for + */ +const hasClassAncestor = (targetEl: EventTarget | null, className: string) => { + let el: Node | null = targetEl instanceof Node ? targetEl : null; + while (el && el instanceof HTMLElement) { + if (el.classList.contains(className)) { + return true; + } + el = el.parentNode; + } + return false; +}; + +export interface FlyoutProps { + /** Button and overlay elements */ + children: React.ReactNode; + /** Set className to the overlay wrapper */ + className?: string; + /** If set to true, closes the overlay on clicking buttons/links inside of it */ + closeOnClick?: boolean; + /** If set to true, closes the overlay on clicking outside of it */ + closeOnClickOutside?: boolean; + /** Function that will interrogate the click event to determine whether or not to close the overlay if closeOnClick is enabled */ + closeOnClickPredicate?: (event: React.SyntheticEvent) => boolean; + /** If set to true, closes the overlay when window loses focus */ + closeOnWindowBlur?: boolean; + /** Sets tether constrain to scrollParent */ + constrainToScrollParent?: boolean; + /** Sets tether constrain to window */ + constrainToWindow?: boolean; + /** Sets tether constrain to window with pin */ + constrainToWindowWithPin?: boolean; + /** Toggles responsive behavior */ + isResponsive?: boolean; + /** Whether overlay should be visible by default */ + isVisibleByDefault: boolean; + /** Adjusts placement of the overlay (SEE http://tether.io/#options) */ + offset?: string; + /** Will fire this callback when the flyout should close */ + onClose?: () => void; + /** Will fire this callback when the flyout should open */ + onOpen?: () => void; + /** Whether overlay should open on hover */ + openOnHover?: boolean; + /** Time in milliseconds that the button should wait before opening and closing the flyout */ + openOnHoverDelayTimeout?: number; + /** An array of CSS classes for portaled elements in the overlay, used to check whether a click is inside the overlay */ + portaledClasses: Array; + /** Position of the overlay */ + position: + | 'bottom-center' + | 'bottom-left' + | 'bottom-right' + | 'middle-left' + | 'middle-right' + | 'top-center' + | 'top-left' + | 'top-right'; + /** Prop whether to focus first focusable element or not */ + shouldDefaultFocus?: boolean; +} + +interface FlyoutState { + isButtonClicked: boolean; + isVisible: boolean; +} + +class Flyout extends React.Component { + static defaultProps = { + className: '', + closeOnClick: true, + closeOnClickOutside: true, + closeOnWindowBlur: false, + constrainToScrollParent: true, + constrainToWindow: false, + isResponsive: false, + isVisibleByDefault: false, + openOnHover: false, + openOnHoverDelayTimeout: 300, + portaledClasses: [], + position: BOTTOM_RIGHT, + }; + + constructor(props: FlyoutProps) { + super(props); + + this.overlayID = uniqueId('overlay'); + this.overlayButtonID = uniqueId('flyoutbutton'); + this.state = { + isVisible: props.isVisibleByDefault, + isButtonClicked: false, + }; + } + + componentDidUpdate(prevProps: FlyoutProps, prevState: FlyoutState) { + if (!prevState.isVisible && this.state.isVisible) { + const { closeOnClickOutside, closeOnWindowBlur } = this.props; + // When overlay is being opened + if (closeOnClickOutside) { + document.addEventListener('click', this.handleDocumentClickOrWindowBlur, true); + document.addEventListener('contextmenu', this.handleDocumentClickOrWindowBlur, true); + } + if (closeOnWindowBlur) { + window.addEventListener('blur', this.handleDocumentClickOrWindowBlur, true); + } + } else if (prevState.isVisible && !this.state.isVisible) { + // When overlay is being closed + document.removeEventListener('contextmenu', this.handleDocumentClickOrWindowBlur, true); + document.removeEventListener('click', this.handleDocumentClickOrWindowBlur, true); + window.removeEventListener('blur', this.handleDocumentClickOrWindowBlur, true); + } + } + + componentWillUnmount() { + if (this.state.isVisible) { + // Clean-up global click handlers + document.removeEventListener('contextmenu', this.handleDocumentClickOrWindowBlur, true); + document.removeEventListener('click', this.handleDocumentClickOrWindowBlur, true); + window.removeEventListener('blur', this.handleDocumentClickOrWindowBlur, true); + } + + if (this.props.openOnHover && this.hoverDelay) { + clearTimeout(this.hoverDelay); + } + } + + overlayButtonID: string; + + overlayID: string; + + handleOverlayClick = (event: React.SyntheticEvent) => { + const overlayNode = document.getElementById(this.overlayID); + const { closeOnClick, closeOnClickPredicate } = this.props; + if (!closeOnClick || !hasClickableAncestor(overlayNode, event.target)) { + return; + } + if (closeOnClickPredicate && !closeOnClickPredicate(event)) { + return; + } + + this.handleOverlayClose(); + }; + + handleButtonClick = (event: React.UIEvent) => { + const { isVisible } = this.state; + if (isVisible) { + this.closeOverlay(); + } else { + this.openOverlay(); + } + + // In at least one place, .click() is called programmatically + // src/features/presence/Presence.js + // In the programmatic case, the event is not supposed to trigger + // autofocus of the content (TBD if this is truly correct behavior). + // This line was using "event.detail > 0" + // to detect if a click event was from a user, but that made keyboard + // triggers of the button click behave differently than the mouse. + // So, we use "isTrusted" instead. Note: React polyfills for IE11. + // https://developer.mozilla.org/en-US/docs/Web/API/Event/isTrusted + // https://reactjs.org/docs/events.html + + const isButtonClicked = event.isTrusted; + + this.setState({ isButtonClicked }); + + event.preventDefault(); + }; + + hoverDelay: ReturnType | undefined; + + handleButtonHover = () => { + const { openOnHover, openOnHoverDelayTimeout } = this.props; + if (openOnHover) { + clearTimeout(this.hoverDelay); + this.hoverDelay = setTimeout(() => { + this.openOverlay(); + }, openOnHoverDelayTimeout); + } + }; + + handleButtonHoverLeave = () => { + const { openOnHover, openOnHoverDelayTimeout } = this.props; + if (openOnHover) { + clearTimeout(this.hoverDelay); + + this.hoverDelay = setTimeout(() => { + this.closeOverlay(); + }, openOnHoverDelayTimeout); + } + }; + + handleKeyPress = (event: React.KeyboardEvent) => { + if (event.key === KEYS.enter) { + event.preventDefault(); + this.openOverlay(); + this.focusButton(); + } + }; + + openOverlay = () => { + this.setState({ + isVisible: true, + }); + + const { onOpen } = this.props; + if (onOpen) { + onOpen(); + } + }; + + closeOverlay = () => { + this.setState({ + isVisible: false, + }); + + const { onClose } = this.props; + if (onClose) { + onClose(); + } + }; + + focusButton = () => { + const buttonEl = document.getElementById(this.overlayButtonID); + if (buttonEl) { + buttonEl.focus(); + } + }; + + handleOverlayClose = () => { + this.focusButton(); + this.closeOverlay(); + }; + + handleDocumentClickOrWindowBlur = (event: MouseEvent | FocusEvent) => { + const { portaledClasses, closeOnClickOutside, closeOnWindowBlur } = this.props; + const { isVisible } = this.state; + + if (!isVisible || !(closeOnClickOutside || closeOnWindowBlur)) { + return; + } + + const overlayNode = document.getElementById(this.overlayID); + const buttonNode = document.getElementById(this.overlayButtonID); + + const isInsideToggleButton = + (buttonNode && event.target instanceof Node && buttonNode.contains(event.target)) || + buttonNode === event.target; + const isInsideOverlay = + (overlayNode && event.target instanceof Node && overlayNode.contains(event.target)) || + overlayNode === event.target; + const isInside = isInsideToggleButton || isInsideOverlay; + + if (isInside || portaledClasses.some(className => hasClassAncestor(event.target, className))) { + return; + } + + // Only close overlay when the click is outside of the flyout or window loses focus + this.closeOverlay(); + }; + + render() { + const { + children, + className = '', + constrainToScrollParent, + constrainToWindow, + constrainToWindowWithPin, + isResponsive, + offset, + openOnHover, + position, + shouldDefaultFocus, + } = this.props; + const { isButtonClicked, isVisible } = this.state; + const elements = React.Children.toArray(children) as React.ReactElement>[]; + const tetherPosition = positions[position]; + + if (elements.length !== 2) { + throw new Error('Flyout must have exactly two children: A button component and a '); + } + + const overlayButton = elements[0]; + const overlayContent = elements[1]; + + const overlayButtonProps: Record = { + id: this.overlayButtonID, + key: this.overlayButtonID, + onClick: this.handleButtonClick, + onKeyPress: this.handleKeyPress, + onMouseEnter: this.handleButtonHover, + onMouseLeave: this.handleButtonHoverLeave, + role: 'button', + tabIndex: '0', + 'aria-haspopup': OVERLAY_ROLE, + 'aria-expanded': isVisible ? 'true' : 'false', + }; + + if (isVisible) { + overlayButtonProps['aria-controls'] = this.overlayID; + } + + const overlayProps = { + id: this.overlayID, + key: this.overlayID, + role: OVERLAY_ROLE, + onClick: this.handleOverlayClick, + onClose: this.handleOverlayClose, + onMouseEnter: this.handleButtonHover, + onMouseLeave: this.handleButtonHoverLeave, + shouldDefaultFocus: shouldDefaultFocus || (!isButtonClicked && !openOnHover), + 'aria-labelledby': this.overlayButtonID, + } as const; + + const constraints = []; + + if (constrainToScrollParent) { + constraints.push({ + to: 'scrollParent', + attachment: 'together', + }); + } + + if (constrainToWindow) { + constraints.push({ + to: 'window', + attachment: 'together', + }); + } + + if (constrainToWindowWithPin) { + constraints.push({ + to: 'window', + attachment: 'together', + pin: true, + }); + } + + const tetherProps: Record = { + classPrefix: 'flyout-overlay', + attachment: tetherPosition.attachment, + targetAttachment: tetherPosition.targetAttachment, + enabled: isVisible, + classes: { + element: classNames('flyout-overlay', { 'bdl-Flyout--responsive': isResponsive }, className), + }, + constraints, + }; + + if (offset) { + tetherProps.offset = offset; + } else { + switch (position) { + case BOTTOM_CENTER: + case BOTTOM_LEFT: + case BOTTOM_RIGHT: + tetherProps.offset = '-10px 0'; + break; + case TOP_CENTER: + case TOP_LEFT: + case TOP_RIGHT: + tetherProps.offset = '10px 0'; + break; + case MIDDLE_LEFT: + tetherProps.offset = '0 10px'; + break; + case MIDDLE_RIGHT: + tetherProps.offset = '0 -10px'; + break; + default: + // no default + } + } + + return ( + ( +
+ {React.cloneElement(overlayButton, overlayButtonProps)} +
+ )} + renderElement={ref => { + return isVisible ? ( +
+ + {React.cloneElement(overlayContent, overlayProps)} + +
+ ) : null; + }} + /> + ); + } +} + +export default Flyout; diff --git a/src/components/flyout/FlyoutContext.js b/src/components/flyout/FlyoutContext.js.flow similarity index 100% rename from src/components/flyout/FlyoutContext.js rename to src/components/flyout/FlyoutContext.js.flow diff --git a/src/components/flyout/FlyoutContext.ts b/src/components/flyout/FlyoutContext.ts new file mode 100644 index 0000000000..6f740e267b --- /dev/null +++ b/src/components/flyout/FlyoutContext.ts @@ -0,0 +1,9 @@ +import * as React from 'react'; +import noop from 'lodash/noop'; + +export interface FlyoutContextValues { + /** Closes the flyout overlay */ + closeOverlay: () => void; +} + +export default React.createContext({ closeOverlay: noop }); diff --git a/src/components/flyout/Overlay.js b/src/components/flyout/Overlay.js.flow similarity index 100% rename from src/components/flyout/Overlay.js rename to src/components/flyout/Overlay.js.flow diff --git a/src/components/flyout/Overlay.tsx b/src/components/flyout/Overlay.tsx new file mode 100644 index 0000000000..354c1929ed --- /dev/null +++ b/src/components/flyout/Overlay.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import omit from 'lodash/omit'; + +import FocusTrap from '../focus-trap'; + +export interface OverlayProps extends React.HTMLAttributes { + /** Overlay contents */ + children: React.ReactNode; + /** Component class names */ + className?: string; + /** Called when the overlay should close */ + onClose?: () => void; + /** Whether to focus the first focusable element when opened */ + shouldDefaultFocus?: boolean; +} + +class Overlay extends React.Component { + closeOverlay = () => { + const { onClose } = this.props; + if (!onClose) { + return; + } + setTimeout(() => onClose(), 0); + }; + + handleOverlayKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== 'Escape') { + return; + } + event.stopPropagation(); + event.preventDefault(); + this.closeOverlay(); + }; + + render() { + const { children, className, ...rest } = this.props; + const overlayProps = omit(rest, ['onClose']) as Record; + overlayProps.className = classNames('bdl-Overlay', className); + overlayProps.handleOverlayKeyDown = this.handleOverlayKeyDown; + overlayProps.tabIndex = 0; + + return ( + +
{children}
+
+ ); + } +} + +export default Overlay; diff --git a/src/components/flyout/OverlayHeader.tsx b/src/components/flyout/OverlayHeader.tsx index eaac83acd3..b66f6ab481 100644 --- a/src/components/flyout/OverlayHeader.tsx +++ b/src/components/flyout/OverlayHeader.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import classNames from 'classnames'; import CloseButton from '../close-button/CloseButton'; -// @ts-ignore flow import FlyoutContext from './FlyoutContext'; import './OverlayHeader.scss'; @@ -23,7 +22,6 @@ const OverlayHeader = ({ children, className, isOverlayHeaderActionEnabled = fal event.stopPropagation(); } }; - // @ts-ignore TODO: figure out why this is giving a TS error const { closeOverlay } = React.useContext(FlyoutContext); return ( diff --git a/src/components/flyout/__tests__/Flyout.test.js b/src/components/flyout/__tests__/Flyout.test.tsx similarity index 89% rename from src/components/flyout/__tests__/Flyout.test.js rename to src/components/flyout/__tests__/Flyout.test.tsx index ca9660dba7..74cdc71bf7 100644 --- a/src/components/flyout/__tests__/Flyout.test.js +++ b/src/components/flyout/__tests__/Flyout.test.tsx @@ -6,26 +6,37 @@ import sinon from 'sinon'; import Flyout from '../Flyout'; const sandbox = sinon.sandbox.create(); - -const BOTTOM_CENTER = 'bottom-center'; -const BOTTOM_LEFT = 'bottom-left'; -const BOTTOM_RIGHT = 'bottom-right'; -const MIDDLE_LEFT = 'middle-left'; -const MIDDLE_RIGHT = 'middle-right'; -const TOP_CENTER = 'top-center'; -const TOP_LEFT = 'top-left'; -const TOP_RIGHT = 'top-right'; +const getFlyoutInstance = (wrapper: { instance: () => React.Component }) => + wrapper.instance() as InstanceType; + +const BOTTOM_CENTER = 'bottom-center' as const; +const BOTTOM_LEFT = 'bottom-left' as const; +const BOTTOM_RIGHT = 'bottom-right' as const; +const MIDDLE_LEFT = 'middle-left' as const; +const MIDDLE_RIGHT = 'middle-right' as const; +const TOP_CENTER = 'top-center' as const; +const TOP_LEFT = 'top-left' as const; +const TOP_RIGHT = 'top-right' as const; describe('components/flyout/Flyout', () => { - const FakeButton = props => ( - // eslint-disable-next-line react/button-has-type + const FakeButton = (props: React.ButtonHTMLAttributes) => ( ); FakeButton.displayName = 'FakeButton'; /* eslint-disable */ - const FakeOverlay = ({ onClick = () => {}, onClose = () => {}, shouldDefaultFocus = false, ...rest }) => ( + interface FakeOverlayProps extends React.HTMLAttributes { + onClose?: () => void; + shouldDefaultFocus?: boolean; + } + + const FakeOverlay = ({ + onClick = () => {}, + onClose = () => {}, + shouldDefaultFocus = false, + ...rest + }: FakeOverlayProps) => (
@@ -37,7 +48,7 @@ describe('components/flyout/Flyout', () => { FakeOverlay.displayName = 'FakeOverlay'; /* eslint-enable */ - const getWrapper = (props = {}) => { + const getWrapper = (props: Partial> = {}) => { return mount( @@ -76,7 +87,7 @@ describe('components/flyout/Flyout', () => { test('should correctly render a single child button with correct props', () => { const wrapper = getWrapper(); - const instance = wrapper.instance(); + const instance = getFlyoutInstance(wrapper!); const button = wrapper.find(FakeButton); expect(button.length).toBe(1); @@ -99,7 +110,7 @@ describe('components/flyout/Flyout', () => { const button = wrapper.find(FakeButton); expect(button.prop('aria-expanded')).toEqual('true'); - expect(button.prop('aria-controls')).toEqual(wrapper.instance().overlayID); + expect(button.prop('aria-controls')).toEqual(getFlyoutInstance(wrapper!).overlayID); }); test('should not render child overlay when overlay is closed', () => { @@ -117,7 +128,7 @@ describe('components/flyout/Flyout', () => { test('should correctly render a single child overlay with correct props when overlay is open', () => { const wrapper = getWrapper(); - const instance = wrapper.instance(); + const instance = getFlyoutInstance(wrapper!); act(() => { wrapper.setState({ isVisible: true, @@ -288,14 +299,14 @@ describe('components/flyout/Flyout', () => { 'should handle clicks within overlay properly %s', ({ closeOnClick, hasClickableAncestor, shouldCloseOverlay }) => { const wrapper = getWrapper({ closeOnClick }); - const instance = wrapper.instance(); + const instance = getFlyoutInstance(wrapper!); act(() => { instance.setState({ isVisible: true, }); }); - const event = {}; + const event: { target?: EventTarget | null } = {}; if (hasClickableAncestor) { event.target = document.getElementById('button'); } else { @@ -308,19 +319,19 @@ describe('components/flyout/Flyout', () => { sandbox.mock(instance).expects('handleOverlayClose').never(); } act(() => { - instance.handleOverlayClick(event); + instance.handleOverlayClick(event as React.SyntheticEvent); }); }, ); }); describe('handleButtonClick()', () => { - let instance; - let wrapper = null; + let instance: InstanceType; + let wrapper: ReturnType | null = null; beforeEach(() => { wrapper = getWrapper(); - instance = wrapper.instance(); + instance = getFlyoutInstance(wrapper!); }); afterEach(() => { @@ -343,7 +354,7 @@ describe('components/flyout/Flyout', () => { test('should toggle isVisible state when called', () => { const event = { preventDefault: sandbox.stub(), - }; + } as unknown as React.UIEvent; act(() => { instance.setState({ isVisible: currentIsVisible, @@ -359,41 +370,39 @@ describe('components/flyout/Flyout', () => { test('should prevent default when called', () => { const event = { preventDefault: sandbox.mock(), - }; + } as unknown as React.UIEvent; instance.handleButtonClick(event); }); }); describe('handleButtonHover()', () => { test('should call openOverlay() when props.openOnHover is true', () => { - const event = {}; const wrapper = getWrapper({ openOnHover: true }); - const instance = wrapper.instance(); + const instance = getFlyoutInstance(wrapper!); setTimeout(() => { sandbox.mock(instance).expects('openOverlay'); }, 310); // default timeout is 300ms - instance.handleButtonHover(event); + instance.handleButtonHover(); }); test('should not call openOverlay() when props.openOnHover is false', () => { - const event = {}; const wrapper = getWrapper({ openOnHover: false }); - const instance = wrapper.instance(); + const instance = getFlyoutInstance(wrapper!); setTimeout(() => { sandbox.mock(instance).expects('openOverlay').never(); }, 310); // default timeout is 300ms - instance.handleButtonHover(event); + instance.handleButtonHover(); }); test('should be able to set custom timeouts for the openOnHover', () => { const timeout = 100; - const wrapper = getWrapper({ openOnHover: false, openOnHoverDebounceTimeout: timeout }); + const wrapper = getWrapper({ openOnHover: false, openOnHoverDelayTimeout: timeout }); - const instance = wrapper.instance(); + const instance = getFlyoutInstance(wrapper!); setTimeout(() => { sandbox.mock(instance).expects('openOverlay').never(); }, timeout - 10); @@ -402,7 +411,7 @@ describe('components/flyout/Flyout', () => { sandbox.mock(instance).expects('openOverlay'); }, timeout + 10); - instance.handleButtonHover({}); + instance.handleButtonHover(); }); }); @@ -410,13 +419,13 @@ describe('components/flyout/Flyout', () => { test('should call closeOverlay', () => { const wrapper = getWrapper({ openOnHover: false }); - const instance = wrapper.instance(); + const instance = getFlyoutInstance(wrapper!); setTimeout(() => { sandbox.mock(instance).expects('closeOverlay'); }, 310); - instance.handleButtonHoverLeave({}); + instance.handleButtonHoverLeave(); }); }); @@ -429,20 +438,21 @@ describe('components/flyout/Flyout', () => { , ); - const instance = wrapper.instance(); + const instance = getFlyoutInstance(wrapper!); const openOverlaySpy = sandbox.spy(instance, 'openOverlay'); const focusButtonSpy = sandbox.spy(instance, 'focusButton'); + const preventDefault = sandbox.spy(); const event = { key: 'Enter', - preventDefault: sandbox.spy(), - }; + preventDefault, + } as unknown as React.KeyboardEvent; instance.handleKeyPress(event); expect(openOverlaySpy.calledOnce).toBe(true); expect(focusButtonSpy.calledOnce).toBe(true); - expect(event.preventDefault.calledOnce).toBe(true); + expect(preventDefault.calledOnce).toBe(true); }); }); @@ -459,17 +469,14 @@ describe('components/flyout/Flyout', () => { ].forEach(({ currentIsVisible, isVisibleAfterOverlayClosed }) => { test('should toggle isVisible state when called', () => { const wrapper = getWrapper(); - const instance = wrapper.instance(); - const event = { - preventDefault: sandbox.stub(), - }; + const instance = getFlyoutInstance(wrapper!); act(() => { instance.setState({ isVisible: currentIsVisible, }); }); act(() => { - instance.closeOverlay(event); + instance.closeOverlay(); }); expect(instance.state.isVisible).toEqual(isVisibleAfterOverlayClosed); }); @@ -478,11 +485,8 @@ describe('components/flyout/Flyout', () => { test('should call onClose when closeOverlay gets called', () => { const onClose = sandbox.mock(); const wrapper = getWrapper({ onClose }); - const instance = wrapper.instance(); - const event = { - preventDefault: sandbox.stub(), - }; - instance.closeOverlay(event); + const instance = getFlyoutInstance(wrapper!); + instance.closeOverlay(); }); }); @@ -499,17 +503,14 @@ describe('components/flyout/Flyout', () => { ].forEach(({ currentIsVisible, isVisibleAfterOverlayOpened }) => { test('should toggle isVisible state when called', () => { const wrapper = getWrapper(); - const instance = wrapper.instance(); - const event = { - preventDefault: sandbox.stub(), - }; + const instance = getFlyoutInstance(wrapper!); act(() => { instance.setState({ isVisible: currentIsVisible, }); }); act(() => { - instance.openOverlay(event); + instance.openOverlay(); }); expect(instance.state.isVisible).toEqual(isVisibleAfterOverlayOpened); }); @@ -518,23 +519,20 @@ describe('components/flyout/Flyout', () => { test('should call onOpen when openOverlay gets called', () => { const onOpen = sandbox.mock(); const wrapper = getWrapper({ onOpen }); - const instance = wrapper.instance(); - const event = { - preventDefault: sandbox.stub(), - }; - instance.openOverlay(event); + const instance = getFlyoutInstance(wrapper!); + instance.openOverlay(); }); }); describe('tests requiring body mounting', () => { - let attachTo; - let wrapper = null; + let attachTo: HTMLDivElement; + let wrapper: ReturnType | null = null; /** * Helper method to mount things to the correct DOM element * this makes it easier to clean up after ourselves after each test. */ - const mountToBody = component => { + const mountToBody = (component: React.ReactElement) => { wrapper = mount(component, { attachTo }); }; @@ -565,7 +563,7 @@ describe('components/flyout/Flyout', () => { , ); - const instance = wrapper.instance(); + const instance = getFlyoutInstance(wrapper!); const overlayButtonEl = document.getElementById(instance.overlayButtonID); sandbox.mock(overlayButtonEl).expects('focus'); @@ -736,8 +734,8 @@ describe('components/flyout/Flyout', () => { , ); - const instance = wrapper.instance(); - const event = {}; + const instance = getFlyoutInstance(wrapper!); + const event: { target?: EventTarget | null } = {}; act(() => { instance.setState({ @@ -759,7 +757,7 @@ describe('components/flyout/Flyout', () => { event.target = document.createElement('div'); } - instance.handleDocumentClickOrWindowBlur(event); + instance.handleDocumentClickOrWindowBlur(event as unknown as MouseEvent); }); }, ); @@ -771,7 +769,7 @@ describe('components/flyout/Flyout', () => { , ); - const instance = wrapper.instance(); + const instance = getFlyoutInstance(wrapper!); const el = document.createElement('div'); el.innerHTML = '
'; @@ -779,7 +777,7 @@ describe('components/flyout/Flyout', () => { instance.handleDocumentClickOrWindowBlur({ target: el.querySelector('.target'), - }); + } as unknown as MouseEvent); }); test('should close overlay when event target is not inside portaled classes element', () => { @@ -789,13 +787,13 @@ describe('components/flyout/Flyout', () => { , ); - const instance = wrapper.instance(); + const instance = getFlyoutInstance(wrapper!); sandbox.mock(instance).expects('closeOverlay'); instance.handleDocumentClickOrWindowBlur({ target: document.createElement('div'), - }); + } as unknown as MouseEvent); }); }); }); @@ -829,7 +827,7 @@ describe('components/flyout/Flyout', () => { ].forEach(({ prevIsVisible, currIsVisible, shouldAddEventListener, shouldRemoveEventListener }) => { test('should remove and add event listeners properly', () => { const wrapper = getWrapper({ isVisibleByDefault: prevIsVisible }); - const instance = wrapper.instance(); + const instance = getFlyoutInstance(wrapper!); const documentMock = sandbox.mock(document); if (shouldAddEventListener) { @@ -864,7 +862,7 @@ describe('components/flyout/Flyout', () => { ].forEach(({ isVisible, shouldRemoveEventListener }) => { test('should remove event listeners only when the overlay is visible', () => { const wrapper = getWrapper(); - const instance = wrapper.instance(); + const instance = getFlyoutInstance(wrapper!); const documentMock = sandbox.mock(document); act(() => { @@ -888,7 +886,7 @@ describe('components/flyout/Flyout', () => { describe('handleOverlayClose()', () => { test('should call focusButton() and closeOverlay() when called', () => { const wrapper = getWrapper(); - const instance = wrapper.instance(); + const instance = getFlyoutInstance(wrapper!); sandbox.mock(instance).expects('focusButton'); sandbox.mock(instance).expects('closeOverlay'); diff --git a/src/components/flyout/__tests__/Overlay.test.js b/src/components/flyout/__tests__/Overlay.test.tsx similarity index 77% rename from src/components/flyout/__tests__/Overlay.test.js rename to src/components/flyout/__tests__/Overlay.test.tsx index c3d11f6b2f..05a1c8a964 100644 --- a/src/components/flyout/__tests__/Overlay.test.js +++ b/src/components/flyout/__tests__/Overlay.test.tsx @@ -5,7 +5,9 @@ import sinon from 'sinon'; import Overlay from '../Overlay'; const sandbox = sinon.sandbox.create(); -let clock; +let clock: sinon.SinonFakeTimers; +const getOverlayInstance = (wrapper: { instance: () => React.Component }) => + wrapper.instance() as InstanceType; describe('components/flyout/Overlay', () => { beforeEach(() => { @@ -44,8 +46,8 @@ describe('components/flyout/Overlay', () => { , ); - const instance = wrapper.instance(); - sandbox.stub(instance, 'focusFirstItem'); + const instance = getOverlayInstance(wrapper); + sandbox.stub(instance, 'focusFirstItem' as keyof InstanceType); instance.closeOverlay(); clock.tick(0); @@ -59,8 +61,8 @@ describe('components/flyout/Overlay', () => { , ); - const instance = wrapper.instance(); - sandbox.stub(instance, 'focusFirstItem'); + const instance = getOverlayInstance(wrapper); + sandbox.stub(instance, 'focusFirstItem' as keyof InstanceType); instance.closeOverlay(); clock.tick(0); @@ -69,7 +71,7 @@ describe('components/flyout/Overlay', () => { describe('handleKeyDown()', () => { const id = 'overlay-0'; - let wrapper; + let wrapper: ReturnType; beforeEach(() => { wrapper = mount( @@ -85,20 +87,20 @@ describe('components/flyout/Overlay', () => { stopPropagation: sandbox.mock(), preventDefault: sandbox.mock(), }; - const instance = wrapper.instance(); + const instance = getOverlayInstance(wrapper); sandbox.mock(instance).expects('closeOverlay'); - instance.handleOverlayKeyDown(event); + instance.handleOverlayKeyDown(event as unknown as React.KeyboardEvent); }); test('should not prevent default or stop propagation when event.key is not Escape', () => { - const instance = wrapper.instance(); + const instance = getOverlayInstance(wrapper); const event = { key: 'LOL', target: { id: 'randomstuff' }, stopPropagation: sandbox.mock().never(), preventDefault: sandbox.mock().never(), }; - instance.handleOverlayKeyDown(event); + instance.handleOverlayKeyDown(event as unknown as React.KeyboardEvent); }); }); }); diff --git a/src/components/flyout/__tests__/OverlayHeader.test.js b/src/components/flyout/__tests__/OverlayHeader.test.tsx similarity index 100% rename from src/components/flyout/__tests__/OverlayHeader.test.js rename to src/components/flyout/__tests__/OverlayHeader.test.tsx diff --git a/src/components/flyout/index.js b/src/components/flyout/index.js.flow similarity index 100% rename from src/components/flyout/index.js rename to src/components/flyout/index.js.flow diff --git a/src/components/flyout/index.ts b/src/components/flyout/index.ts new file mode 100644 index 0000000000..b40af8aad1 --- /dev/null +++ b/src/components/flyout/index.ts @@ -0,0 +1,8 @@ +export { default as Flyout } from './Flyout'; +export type { FlyoutProps } from './Flyout'; +export { default as FlyoutContext } from './FlyoutContext'; +export type { FlyoutContextValues } from './FlyoutContext'; +export { default as Overlay } from './Overlay'; +export type { OverlayProps } from './Overlay'; +export { default as OverlayHeader } from './OverlayHeader'; +export type { OverlayHeaderProps } from './OverlayHeader';