Extract useOnClickAway

This commit is contained in:
Jordan Eldredge 2019-12-05 22:15:36 -08:00
parent e815d62f6e
commit 75d1420ae4
2 changed files with 73 additions and 63 deletions

View file

@ -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<ReactNode>;
}
interface State {
selected: boolean;
}
export default class PlaylistMenu extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { selected: false };
}
export default function PlaylistMenu(props: Props) {
const [selected, setSelected] = useState(false);
_handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
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<Element | null>(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 (
<div
id={this.props.id}
className={classnames("playlist-menu", {
selected: this.state.selected,
})}
onClick={this._handleClick}
>
<div className="bar" />
{this.state.selected && (
<ul>
{React.Children.map(this.props.children, (child, i) => (
<PlaylistMenuEnry key={i}>{child}</PlaylistMenuEnry>
))}
</ul>
)}
</div>
);
}
return (
<div
id={props.id}
className={classnames("playlist-menu", {
selected,
})}
ref={setRef}
onClick={() => setSelected(selected_ => !selected_)}
>
<div className="bar" />
{selected && (
<ul>
{React.Children.map(props.children, (child, i) => (
<PlaylistMenuEnry key={i}>{child}</PlaylistMenuEnry>
))}
</ul>
)}
</div>
);
}

View file

@ -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<T>(selector: (state: AppState) => T): T {
return useSelector(selector);