From 75d1420ae428599c8abfae9e80f9e960b0edeaa1 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Thu, 5 Dec 2019 22:15:36 -0800 Subject: [PATCH] Extract useOnClickAway --- js/components/PlaylistWindow/PlaylistMenu.tsx | 99 +++++++------------ js/hooks.ts | 37 +++++++ 2 files changed, 73 insertions(+), 63 deletions(-) diff --git a/js/components/PlaylistWindow/PlaylistMenu.tsx b/js/components/PlaylistWindow/PlaylistMenu.tsx index 8d34522e..62815f04 100644 --- a/js/components/PlaylistWindow/PlaylistMenu.tsx +++ b/js/components/PlaylistWindow/PlaylistMenu.tsx @@ -1,80 +1,53 @@ -import React from "react"; +import React, { useState, ReactNode, useCallback } from "react"; import classnames from "classnames"; +import { useOnClickAway } from "../../hooks"; import PlaylistMenuEnry from "./PlaylistMenuEntry"; interface Props { id: string; + children: ReactNode | Array; } interface State { selected: boolean; } -export default class PlaylistMenu extends React.Component { - constructor(props: Props) { - super(props); - this.state = { selected: false }; - } +export default function PlaylistMenu(props: Props) { + const [selected, setSelected] = useState(false); - _handleClick = (e: React.MouseEvent) => { - const { target } = e; - if (!(target instanceof Element)) { - // TypeScript doesn't realize this will always be true - return; - } - const { selected } = this.state; - if (selected) { - this.setState({ selected: false }); - return; - } + const [ref, setRef] = useState(null); - const handleClickOut = (ee: MouseEvent) => { - const clickOutTarget = ee.target; - if (!(clickOutTarget instanceof Element)) { - // TypeScript doesn't realize this will always be true - return; - } - // If the click is _not_ inside the menu. - if (!target.contains(clickOutTarget)) { - // If we've clicked on a Context Menu spawed inside this menu, it will - // register as an external click. However, hiding the menu will remove - // the Context Menu from the DOM. Therefore, we wait until the next - // event loop to actually hide ourselves. - setTimeout(() => { - // Close the menu - this.setState({ selected: false }); - }, 0); - window.document.removeEventListener("click", handleClickOut, { - capture: true, - }); - } - }; - window.document.addEventListener("click", handleClickOut, { - capture: true, - }); + const callback = useCallback(() => { + // If we've clicked on a Context Menu spawed inside this menu, it will + // register as an external click. However, hiding the menu will remove + // the Context Menu from the DOM. Therefore, we wait until the next + // event loop to actually hide ourselves. + setTimeout(() => { + // Close the menu + setSelected(false); + }, 0); + }, []); - this.setState({ selected: true }); - }; + useOnClickAway(ref, selected ? callback : null); - render() { - return ( -
-
- {this.state.selected && ( -
    - {React.Children.map(this.props.children, (child, i) => ( - {child} - ))} -
- )} -
- ); - } + return ( +
setSelected(selected_ => !selected_)} + > +
+ {selected && ( +
    + {React.Children.map(props.children, (child, i) => ( + {child} + ))} +
+ )} +
+ ); } diff --git a/js/hooks.ts b/js/hooks.ts index c8b5546f..d5cc9f68 100644 --- a/js/hooks.ts +++ b/js/hooks.ts @@ -91,6 +91,43 @@ export function useIsHovered() { return { ref: setNode, hover }; } +export function useOnClickAway( + ref: Element | null, + callback: null | (() => void) +) { + useEffect(() => { + if (ref == null || callback == null) { + return; + } + + const handleClickOut = (ee: MouseEvent) => { + const clickOutTarget = ee.target; + if (!(clickOutTarget instanceof Element)) { + // TypeScript doesn't realize this will always be true + return; + } + if (ref.contains(clickOutTarget)) { + return; + } + // If the click is _not_ inside the menu. + callback(); + window.document.removeEventListener("click", handleClickOut, { + capture: true, + }); + }; + + window.document.addEventListener("click", handleClickOut, { + capture: true, + }); + + return () => { + window.document.removeEventListener("click", handleClickOut, { + capture: true, + }); + }; + }, [ref, callback]); +} + // TODO: Return useSelector directly and apply the type without wrapping export function useTypedSelector(selector: (state: AppState) => T): T { return useSelector(selector);