@workday/canvas-kit-react
Version:
The parent module that contains all Workday Canvas Kit React components
127 lines (118 loc) • 4.92 kB
text/typescript
import React from 'react';
import {PopupStack} from '@workday/canvas-kit-popup-stack';
import {useLocalRef, useIsRTL, useCanvasThemeToCssVars} from '@workday/canvas-kit-react/common';
import {ThemeContext, Theme} from '@emotion/react';
/**
* **Note:** If you're using {@link Popper}, you do not need to use this hook directly.
*
* This hook will add the `stackRef` element to the {@link PopupStack} on mount and remove on unmount. If
* you use `Popper`, the popper `stackRef` is automatically added/removed from the `PopupStack`. The
* `PopupStack` is required for proper z-index values to ensure Popups are rendered correct. It is
* also required for global listeners like click outside or escape key closing a popup. Without the
* `PopupStack`, all popups will close rather than only the topmost one.
*
* If `ref` is provided, it will be the same as `stackRef`. If `ref` is not provided`,
* this hook will create one and return it.
*
* This hook should be used by all stacked UIs unless using the `Popper` component.
*
* ```tsx
* const model = usePopupModel();
* usePopupStack(model.state.stackRef, model.state.targetRef);
*
* // add some popup functionality
* useCloseOnOutsideClick(model);
* useCloseOnEscape(model);
*
* return (
* <>
* <button ref={model.state.targetRef}>Open Popup</button>
* {model.state.visibility !== 'hidden'
* ? ReactDOM.createPortal(<div>Popup Contents</div>, model.state.stackRef.current)
* : null}
* </>
* );
* ```
*
* @param ref This ref will be managed by the PopupStack and should not be managed by React. Do
* not apply this stackRef to a React element. Doing so will result in an error. Instead, use this
* `stackRef` directly with `ReactDOM.createPortal(<YourComponent>, stackRef.current!)`. This is
* definitely strange for React code, but is necessary for the PopupStack to remain framework
* agnostic and flexible to integrate with existing `PopupStack` systems. If not provided, this hook
* will create one and return that `stackRef` instead.
* @param target Usually the trigger of a popup. This will fix `bringToTop` and should be provided
* by all ephemeral-type popups (like Tooltips, Select menus, etc). It will also add in clickOutside
* detection.
*/
export const usePopupStack = <E extends HTMLElement>(
ref?: React.Ref<E>,
target?: HTMLElement | React.RefObject<HTMLElement>
): React.RefObject<HTMLElement> => {
const {elementRef, localRef} = useLocalRef(ref);
const isRTL = useIsRTL();
const theme = React.useContext(ThemeContext as React.Context<Theme>);
const {className, style} = useCanvasThemeToCssVars(theme, {});
// useState function input ensures we only create a container once.
const [popupRef] = React.useState(() => {
const container = PopupStack.createContainer();
elementRef(container as E);
return container;
});
// We useLayoutEffect to ensure proper timing of registration of the element to the popup stack.
// Without this, the timing is unpredictable when mixed with other frameworks. Other frameworks
// should also register as soon as the element is available
React.useLayoutEffect(() => {
if (popupRef !== localRef.current) {
throw Error(
`The 'ref' passed to usePopupStack should not be applied to a React element. This will cause a runtime error where the PopupStack and React compete for the element. Instead use ReactDOM.createPortal(<YourComponent />, ref.current)`
);
}
const targetEl = target
? 'current' in target
? target.current || undefined
: target
: undefined;
const element = localRef.current!;
PopupStack.add({element: element, owner: targetEl});
return () => {
PopupStack.remove(element);
};
}, [localRef, target, popupRef]);
// The direction will properly follow the theme via React context, but portals lose the `dir`
// hierarchy, so we'll add it back here.
React.useLayoutEffect(() => {
if (isRTL) {
localRef.current?.setAttribute('dir', 'rtl');
} else {
localRef.current?.removeAttribute('dir');
}
}, [localRef, isRTL]);
// theming className
React.useLayoutEffect(() => {
const element = localRef.current;
element?.classList.add(className.trim());
return () => {
element?.classList.remove(className.trim());
};
}, [localRef, className]);
React.useLayoutEffect(() => {
const element = localRef.current;
if (element) {
// eslint-disable-next-line guard-for-in
for (const key in style) {
// @ts-ignore
element.style.setProperty(key, style[key]);
}
}
return () => {
if (element) {
// eslint-disable-next-line guard-for-in
for (const key in style) {
// @ts-ignore
element.style.removeProperty(key, style[key]);
}
}
};
}, [localRef, style]);
return localRef;
};