262 lines
6.7 KiB
TypeScript
Executable File
262 lines
6.7 KiB
TypeScript
Executable File
import {
|
|
FloatingFocusManager,
|
|
FloatingOverlay,
|
|
FloatingPortal,
|
|
useClick,
|
|
useDismiss,
|
|
useFloating,
|
|
useId,
|
|
useInteractions,
|
|
useMergeRefs,
|
|
useRole,
|
|
} from "@floating-ui/react";
|
|
import { useLocal } from "lib/utils/use-local";
|
|
import * as React from "react";
|
|
|
|
interface ModalOptions {
|
|
initialOpen?: boolean;
|
|
open?: boolean;
|
|
onOpenChange?: (open: boolean) => void;
|
|
fade?: boolean;
|
|
}
|
|
|
|
export function useModal({
|
|
initialOpen = true,
|
|
open: controlledOpen,
|
|
onOpenChange: setControlledOpen,
|
|
}: ModalOptions) {
|
|
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(initialOpen);
|
|
const [labelId, setLabelId] = React.useState<string | undefined>();
|
|
const [descriptionId, setDescriptionId] = React.useState<
|
|
string | undefined
|
|
>();
|
|
|
|
const open = controlledOpen ?? uncontrolledOpen;
|
|
const setOpen = setControlledOpen ?? setUncontrolledOpen;
|
|
|
|
const data = useFloating({
|
|
open,
|
|
onOpenChange: setOpen,
|
|
});
|
|
|
|
const context = data.context;
|
|
|
|
const click = useClick(context, {
|
|
enabled: controlledOpen == null,
|
|
});
|
|
const dismiss = useDismiss(context, {
|
|
outsidePressEvent: "mousedown",
|
|
escapeKey: false,
|
|
});
|
|
const role = useRole(context);
|
|
|
|
const interactions = useInteractions([click, dismiss, role]);
|
|
|
|
return React.useMemo(
|
|
() => ({
|
|
open,
|
|
setOpen,
|
|
...interactions,
|
|
...data,
|
|
labelId,
|
|
descriptionId,
|
|
setLabelId,
|
|
setDescriptionId,
|
|
}),
|
|
[open, setOpen, interactions, data, labelId, descriptionId]
|
|
);
|
|
}
|
|
|
|
type ContextType =
|
|
| (ReturnType<typeof useModal> & {
|
|
setLabelId: React.Dispatch<React.SetStateAction<string | undefined>>;
|
|
setDescriptionId: React.Dispatch<
|
|
React.SetStateAction<string | undefined>
|
|
>;
|
|
})
|
|
| null;
|
|
|
|
const ModalContext = React.createContext<ContextType>(null);
|
|
|
|
export const useModalContext = () => {
|
|
const context = React.useContext(ModalContext);
|
|
|
|
if (context == null) {
|
|
throw new Error("Modal components must be wrapped in <Modal />");
|
|
}
|
|
|
|
return context;
|
|
};
|
|
|
|
export function Modal({
|
|
children,
|
|
...options
|
|
}: {
|
|
children: React.ReactNode;
|
|
} & ModalOptions) {
|
|
const dialog = useModal(options);
|
|
return (
|
|
<ModalContext.Provider value={dialog}>
|
|
<ModalContent fade={options.fade} className={cx("modal", "outline-none")}>
|
|
{children}
|
|
</ModalContent>
|
|
</ModalContext.Provider>
|
|
);
|
|
}
|
|
|
|
interface ModalTriggerProps {
|
|
children: React.ReactNode;
|
|
asChild?: boolean;
|
|
}
|
|
|
|
export const ModalTrigger = React.forwardRef<
|
|
HTMLElement,
|
|
React.HTMLProps<HTMLElement> & ModalTriggerProps
|
|
>(function ModalTrigger({ children, asChild = false, ...props }, propRef) {
|
|
const context = useModalContext();
|
|
const childrenRef = (children as any).ref;
|
|
const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef]);
|
|
|
|
// `asChild` allows the user to pass any element as the anchor
|
|
if (asChild && React.isValidElement(children)) {
|
|
return React.cloneElement(
|
|
children,
|
|
context.getReferenceProps({
|
|
ref,
|
|
...props,
|
|
...children.props,
|
|
"data-state": context.open ? "open" : "closed",
|
|
})
|
|
);
|
|
}
|
|
|
|
return (
|
|
<button
|
|
ref={ref}
|
|
data-state={context.open ? "open" : "closed"}
|
|
{...context.getReferenceProps(props as any)}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
});
|
|
|
|
export const ModalContent = React.forwardRef<
|
|
HTMLDivElement,
|
|
React.HTMLProps<HTMLDivElement> & { fade?: boolean }
|
|
>(function ModalContent(props, propRef) {
|
|
const local = useLocal({ preview: false, timeout: null as any });
|
|
const { context: floatingContext, ...context } = useModalContext();
|
|
const ref = useMergeRefs([context.refs.setFloating, propRef]);
|
|
|
|
if (!floatingContext.open) return null;
|
|
const _props = { ...props };
|
|
if (typeof _props.fade !== "undefined") {
|
|
delete _props.fade;
|
|
}
|
|
|
|
const floatingDivProps = context.getFloatingProps(_props);
|
|
return (
|
|
<FloatingPortal>
|
|
<FloatingOverlay
|
|
className={cx(
|
|
"modal-overlay",
|
|
"flex items-center justify-center transition-all ",
|
|
css`
|
|
background: rgba(0, 0, 0, 0.3);
|
|
display: grid;
|
|
place-items: center;
|
|
`,
|
|
local.preview ? "opacity-20 duration-1000" : "duration-300"
|
|
)}
|
|
lockScroll
|
|
>
|
|
<FloatingFocusManager context={floatingContext}>
|
|
<div
|
|
ref={ref}
|
|
onPointerMove={() => {
|
|
if (props.fade !== false) {
|
|
clearTimeout(local.timeout);
|
|
if (local.preview) {
|
|
local.preview = false;
|
|
local.render();
|
|
}
|
|
}
|
|
}}
|
|
onPointerLeave={(e) => {
|
|
// if (Object.keys(w.openedPopupID || {}).length > 0) {
|
|
// return;
|
|
// }
|
|
|
|
if (props.fade !== false) {
|
|
clearTimeout(local.timeout);
|
|
local.timeout = setTimeout(() => {
|
|
local.preview = true;
|
|
local.render();
|
|
}, 1000);
|
|
}
|
|
}}
|
|
aria-labelledby={context.labelId}
|
|
aria-describedby={context.descriptionId}
|
|
{...floatingDivProps}
|
|
>
|
|
{props.children}
|
|
</div>
|
|
</FloatingFocusManager>
|
|
</FloatingOverlay>
|
|
</FloatingPortal>
|
|
);
|
|
});
|
|
|
|
export const ModalHeading = React.forwardRef<
|
|
HTMLHeadingElement,
|
|
React.HTMLProps<HTMLHeadingElement>
|
|
>(function ModalHeading({ children, ...props }, ref) {
|
|
const { setLabelId } = useModalContext();
|
|
const id = useId();
|
|
|
|
// Only sets `aria-labelledby` on the Modal root element
|
|
// if this component is mounted inside it.
|
|
React.useLayoutEffect(() => {
|
|
setLabelId(id);
|
|
return () => setLabelId(undefined);
|
|
}, [id, setLabelId]);
|
|
|
|
return (
|
|
<h2 {...props} ref={ref} id={id}>
|
|
{children}
|
|
</h2>
|
|
);
|
|
});
|
|
|
|
export const ModalDescription = React.forwardRef<
|
|
HTMLParagraphElement,
|
|
React.HTMLProps<HTMLParagraphElement>
|
|
>(function ModalDescription({ children, ...props }, ref) {
|
|
const { setDescriptionId } = useModalContext();
|
|
const id = useId();
|
|
|
|
// Only sets `aria-describedby` on the Modal root element
|
|
// if this component is mounted inside it.
|
|
React.useLayoutEffect(() => {
|
|
setDescriptionId(id);
|
|
return () => setDescriptionId(undefined);
|
|
}, [id, setDescriptionId]);
|
|
|
|
return (
|
|
<p {...props} ref={ref} id={id}>
|
|
{children}
|
|
</p>
|
|
);
|
|
});
|
|
|
|
export const ModalClose = React.forwardRef<
|
|
HTMLButtonElement,
|
|
React.ButtonHTMLAttributes<HTMLButtonElement>
|
|
>(function ModalClose(props, ref) {
|
|
const { setOpen } = useModalContext();
|
|
return (
|
|
<button type="button" {...props} ref={ref} onClick={() => setOpen(false)} />
|
|
);
|
|
});
|