Skip to content

Instantly share code, notes, and snippets.

@blvdmitry
Last active January 11, 2022 17:28
Show Gist options
  • Save blvdmitry/3361c642ba4869325fce5baba3899ab9 to your computer and use it in GitHub Desktop.
Save blvdmitry/3361c642ba4869325fce5baba3899ab9 to your computer and use it in GitHub Desktop.
/* css */
.root {
--_p: 4;
padding: calc(var(--arcade-unit-x1) * var(--_p));
border-radius: var(--arcade-unit-radius-medium);
background: var(--arcade-color-background-elevated);
color: var(--arcade-color-foreground-neutral);
border: var(--arcade-unit-border-small) solid var(--arcade-color-border-neutral-faded);
box-shadow: var(--arcade-shadow-elevated);
min-width: 220px;
max-width: 360px;
overflow: hidden;
}
.root.--padded {
}
.root.--has-width {
max-width: none;
min-width: 0;
}
@media (--arcade-viewport-s) {
.root {
max-width: none;
}
}
/* Popover */
import React from "react";
import { classNames } from "utilities/helpers";
import Flyout, { FlyoutRefProps } from "components/_private/Flyout";
import type * as T from "./Popover.types";
import s from "./Popover.module.css";
const Popover = (props: T.Props) => {
const {
id,
forcePosition,
onOpen,
onClose,
active,
defaultActive,
children,
width,
padding,
triggerType = "click",
position = "bottom",
} = props;
const flyoutRef = React.useRef<FlyoutRefProps | null>(null);
const contentClassName = classNames(s.root, !!width && s["--has-width"]);
const trapFocusMode =
props.trapFocusMode || (triggerType === "hover" ? "content-menu" : undefined);
return (
// @ts-ignore
<Flyout
id={id}
ref={flyoutRef}
position={position}
forcePosition={forcePosition}
onOpen={onOpen}
onClose={onClose}
trapFocusMode={trapFocusMode}
triggerType={triggerType}
active={active}
defaultActive={defaultActive}
width={width}
contentClassName={contentClassName}
contentAttributes={{ style: { "--_p": padding } }}
>
{children}
</Flyout>
);
};
Popover.Content = Flyout.Content;
Popover.Trigger = Flyout.Trigger;
export default Popover;
/* Flyout */
import React from "react";
import { debounce } from "utilities/helpers";
import { trapFocus } from "utilities/a11y";
import * as keys from "constants/keys";
import * as timeouts from "constants/timeouts";
import useIsDismissible from "hooks/useIsDismissible";
import useElementId from "hooks/useElementId";
import useIsomorphicLayoutEffect from "hooks/useIsomorphicLayoutEffect";
import useFlyout from "hooks/useFlyout";
import useKeyboardCallback from "hooks/useKeyboardCallback";
import useOnClickOutside from "hooks/useOnClickOutside";
import useRTL from "hooks/useRTL";
import { Provider } from "./Flyout.context";
import type * as T from "./Flyout.types";
const FlyoutRoot = (props: T.ControlledProps & T.DefaultProps, ref: T.Ref) => {
const {
triggerType = "click",
onOpen,
onClose,
children,
forcePosition,
trapFocusMode,
width,
contentClassName,
contentAttributes,
position: passedPosition,
active: passedActive,
id: passedId,
} = props;
const [isRTL] = useRTL();
const triggerElRef = React.useRef<HTMLElement | null>(null);
const flyoutElRef = React.useRef<HTMLDivElement | null>(null);
const id = useElementId(passedId);
const timerRef = React.useRef<ReturnType<typeof setTimeout>>();
const releaseFocusRef = React.useRef<ReturnType<typeof trapFocus> | null>(null);
const lockedRef = React.useRef(false);
const shouldReturnFocusRef = React.useRef(true);
const flyout = useFlyout(triggerElRef, flyoutElRef, {
width,
position: passedPosition,
defaultActive: passedActive,
forcePosition,
});
const { active, update, open, hide, remove, visible } = flyout;
// Don't create dismissible queue for hover flyout because they close all together on mouseout
const isDismissible = useIsDismissible(triggerElRef, triggerType !== "hover" && active);
const updatePosition = React.useMemo(() => debounce(update, 10), [update]);
const clearTimer = React.useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
}, [timerRef]);
const handleOpen = React.useCallback(() => {
if (active || lockedRef.current) return;
if (onOpen) onOpen();
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [active, triggerType]);
const handleClose = React.useCallback(() => {
const canClose = triggerType !== "click" || isDismissible();
if (!active || !canClose) return;
if (onClose) onClose();
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [active, isDismissible, triggerType]);
const handleBlur = React.useCallback(
(e: React.FocusEvent<HTMLElement>) => {
if (releaseFocusRef.current) return;
// Empty flyouts don't move the focus so they have to be closed on blur
// @ts-ignore
const focusedContent = flyoutElRef.current?.contains(e.relatedTarget as Node);
if (triggerType === "click" && focusedContent) return;
handleClose();
},
[handleClose, triggerType]
);
const handleFocus = React.useCallback(() => {
if (triggerType !== "hover") return;
handleOpen();
}, [handleOpen, triggerType]);
/**
* Hover trigger handlers
* Both handlers opening/closing when a mouse is randomly moved around the screen
*/
const handleMouseEnter = React.useCallback(() => {
if (triggerType !== "hover") return;
clearTimer();
timerRef.current = setTimeout(handleOpen, timeouts.mouseEnter);
}, [clearTimer, timerRef, handleOpen, triggerType]);
const handleMouseLeave = React.useCallback(() => {
if (triggerType !== "hover") return;
clearTimer();
timerRef.current = setTimeout(handleClose, timeouts.mouseLeave);
}, [clearTimer, timerRef, handleClose, triggerType]);
/**
* Click trigger handlers including keyboard navigation
*/
const handleTriggerClick = React.useCallback(() => {
if (active) {
handleClose();
return;
}
handleOpen();
}, [active, handleOpen, handleClose]);
const handleTransitionEnd = React.useCallback(() => {
if (visible || !active) return;
remove();
}, [remove, visible, active]);
/**
* Open flyout when active property changes
*/
useIsomorphicLayoutEffect(() => {
if (!passedActive) {
hide();
return;
}
open();
}, [passedActive, open, hide]);
/**
* Handle flyout close
* We release focus on visible change to not wait till animation ends
* so if we click outside the flyout, it won't focus the trigger
* after the animation and open it again
*/
React.useEffect(() => {
if (visible) return;
if (releaseFocusRef.current) {
/* Locking the popover to not open it again on trigger focus */
if (triggerType === "hover") {
lockedRef.current = true;
setTimeout(() => {
lockedRef.current = false;
}, 100);
}
releaseFocusRef.current({
withoutFocusReturn: !shouldReturnFocusRef.current,
});
releaseFocusRef.current = null;
shouldReturnFocusRef.current = true;
}
}, [visible, triggerType]);
/**
* Handle flyout open
*/
React.useEffect(() => {
if (!visible) return;
if (flyoutElRef.current) {
releaseFocusRef.current = trapFocus(flyoutElRef.current!, {
mode: trapFocusMode,
// TODO: Turn includeTrigger on for input text and textarea
includeTrigger: triggerType === "hover",
onNavigateOutside: () => {
releaseFocusRef.current = null;
handleClose();
},
});
}
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [visible, triggerType]);
/**
* Release focus trapping on unmount
*/
React.useEffect(() => {
if (!active) return;
return () => {
if (releaseFocusRef.current) releaseFocusRef.current();
releaseFocusRef.current = null;
};
}, [active]);
React.useEffect(() => {
window.addEventListener("resize", updatePosition);
return () => window.removeEventListener("resize", updatePosition);
}, [updatePosition]);
React.useEffect(() => {
updatePosition();
}, [isRTL, updatePosition]);
React.useImperativeHandle(
ref,
() => ({
open: handleOpen,
close: handleClose,
}),
[handleOpen, handleClose]
);
useKeyboardCallback(
keys.ESC,
() => {
handleClose();
},
[handleClose]
);
useOnClickOutside([flyoutElRef, triggerElRef], () => {
// Clicking outside changes focused element so we don't need to set it back ourselves
shouldReturnFocusRef.current = false;
handleClose();
});
return (
<Provider
value={{
id,
flyout,
triggerElRef,
flyoutElRef,
handleClose,
handleFocus,
handleBlur,
handleMouseEnter,
handleMouseLeave,
handleTransitionEnd,
handleClick: handleTriggerClick,
triggerType,
trapFocusMode,
contentClassName,
contentAttributes,
}}
>
{children}
</Provider>
);
};
export default React.forwardRef(FlyoutRoot);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment