Last active
January 11, 2022 17:28
-
-
Save blvdmitry/3361c642ba4869325fce5baba3899ab9 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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