import type { DTransitionState } from '../_transition';
import type { DDrawerFooterPrivateProps } from './DrawerFooter';
import type { DDrawerHeaderPrivateProps } from './DrawerHeader';
import type { DRefExtra } from '@react-devui/hooks/useRefExtra';
import { isString, isUndefined } from 'lodash';
import React, { useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { useId, useRefExtra } from '@react-devui/hooks';
import { getClassName, toPx } from '@react-devui/utils';
import { useMaxIndex, useDValue, useLockScroll } from '../../hooks';
import { registerComponentMate, handleModalKeyDown, TTANSITION_DURING_BASE, checkNoExpandedEl } from '../../utils';
import { DMask } from '../_mask';
import { DTransition } from '../_transition';
import { useComponentConfig, usePrefixConfig } from '../root';
import { DDrawerFooter } from './DrawerFooter';
import { DDrawerHeader } from './DrawerHeader';
export interface DDrawerProps extends React.HTMLAttributes<HTMLDivElement> {
dVisible: boolean;
dInitialVisible?: boolean;
dContainer?: DRefExtra | false;
dPlacement?: 'top' | 'right' | 'bottom' | 'left';
dWidth?: number | string;
dHeight?: number | string;
dZIndex?: number | string;
dMask?: boolean;
dMaskClosable?: boolean;
dEscClosable?: boolean;
dSkipFirstTransition?: boolean;
dDestroyAfterClose?: boolean;
dHeader?: React.ReactElement | string;
dFooter?: React.ReactElement;
dChildDrawer?: React.ReactElement;
onClose?: () => void;
afterVisibleChange?: (visible: boolean) => void;
}
export interface DDrawerPrivateProps {
__zIndex?: number | string;
__onVisibleChange?: (distance: { visible: boolean; top: number; right: number; bottom: number; left: number }) => void;
}
const { COMPONENT_NAME } = registerComponentMate({ COMPONENT_NAME: 'DDrawer' as const });
export const DDrawer: {
(props: DDrawerProps): JSX.Element | null;
Header: typeof DDrawerHeader;
Footer: typeof DDrawerFooter;
} = (props) => {
const {
children,
dVisible,
dInitialVisible = false,
dContainer,
dPlacement = 'right',
dWidth = 400,
dHeight = 280,
dZIndex,
dMask = true,
dMaskClosable = true,
dEscClosable = true,
dSkipFirstTransition = true,
dDestroyAfterClose = true,
dHeader,
dFooter,
dChildDrawer,
onClose,
afterVisibleChange,
__zIndex,
__onVisibleChange,
...restProps
} = useComponentConfig(COMPONENT_NAME, props as DDrawerProps & DDrawerPrivateProps);
const dPrefix = usePrefixConfig();
const drawerRef = useRef<HTMLDivElement>(null);
const containerRef = useRefExtra(
isUndefined(dContainer)
? () => {
let el = document.getElementById(`${dPrefix}drawer-root`);
if (!el) {
el = document.createElement('div');
el.id = `${dPrefix}drawer-root`;
document.body.appendChild(el);
}
return el;
}
: dContainer === false
? () => {
return drawerRef.current?.parentElement ?? null;
}
: dContainer,
true
);
const dataRef = useRef<{
prevActiveEl: HTMLElement | null;
}>({
prevActiveEl: null,
});
const uniqueId = useId();
const titleId = `${dPrefix}drawer-title-${uniqueId}`;
const bodyId = `${dPrefix}drawer-content-${uniqueId}`;
const [distance, setDistance] = useState<{ visible: boolean; top: number; right: number; bottom: number; left: number }>({
visible: false,
top: 0,
right: 0,
bottom: 0,
left: 0,
});
const nestedStyle = {
transition: `transform 140ms ${distance.visible ? 'ease-out' : 'ease-in'} 60ms`,
transform:
dPlacement === 'top'
? `translateY(${(distance[dPlacement] / 3) * 2}px)`
: dPlacement === 'right'
? `translateX(${-(distance[dPlacement] / 3) * 2}px)`
: dPlacement === 'bottom'
? `translateY(${-(distance[dPlacement] / 3) * 2}px)`
: `translateX(${(distance[dPlacement] / 3) * 2}px)`,
};
const [visible, changeVisible] = useDValue<boolean>(dInitialVisible, dVisible, onClose);
const isFixed = isUndefined(dContainer);
const maxZIndex = useMaxIndex(visible);
const zIndex = (() => {
if (!isUndefined(dZIndex)) {
return dZIndex;
}
if (isUndefined(__zIndex)) {
if (isFixed) {
return maxZIndex;
} else {
return `var(--${dPrefix}zindex-absolute)`;
}
} else {
return __zIndex;
}
})();
useLockScroll(isFixed && visible);
useEffect(() => {
if (visible) {
const height = isString(dHeight) ? toPx(dHeight, true) : dHeight;
const width = isString(dWidth) ? toPx(dWidth, true) : dWidth;
__onVisibleChange?.({
visible: true,
top: distance.top + (dPlacement === 'top' ? height : 0),
right: distance.right + (dPlacement === 'right' ? width : 0),
bottom: distance.bottom + (dPlacement === 'bottom' ? height : 0),
left: distance.left + (dPlacement === 'left' ? width : 0),
});
} else {
__onVisibleChange?.({
...distance,
visible: false,
});
}
}, [visible]);
const transitionStyles: Partial<Record<DTransitionState, React.CSSProperties>> = (() => {
const transform =
dPlacement === 'top'
? 'translate(0, -100%)'
: dPlacement === 'right'
? 'translate(100%, 0)'
: dPlacement === 'bottom'
? 'translate(0, 100%)'
: 'translate(-100%, 0)';
return {
enter: { transform },
entering: {
transition: ['transform'].map((attr) => `${attr} ${TTANSITION_DURING_BASE}ms ease-out`).join(', '),
},
leaving: {
transform,
transition: ['transform'].map((attr) => `${attr} ${TTANSITION_DURING_BASE}ms ease-in`).join(', '),
},
leaved: { transform },
};
})();
const childDrawer = (() => {
if (dChildDrawer) {
return React.cloneElement<DDrawerPrivateProps>(dChildDrawer, {
__zIndex: isUndefined(zIndex) ? zIndex : `calc(${zIndex} + 1)`,
__onVisibleChange: (distance) => {
setDistance(distance);
},
});
}
return null;
})();
const headerNode = (() => {
if (dHeader) {
const node = isString(dHeader) ? <DDrawerHeader>{dHeader}</DDrawerHeader> : dHeader;
return React.cloneElement<DDrawerHeaderPrivateProps>(node, {
__id: titleId,
__onClose: () => {
changeVisible(false);
},
});
}
})();
const drawerNode = (
<>
<DTransition
dIn={visible}
dDuring={TTANSITION_DURING_BASE}
dSkipFirstTransition={dSkipFirstTransition}
dDestroyWhenLeaved={dDestroyAfterClose}
afterEnter={() => {
afterVisibleChange?.(true);
dataRef.current.prevActiveEl = document.activeElement as HTMLElement | null;
if (drawerRef.current) {
drawerRef.current.focus({ preventScroll: true });
}
}}
afterLeave={() => {
afterVisibleChange?.(false);
if (dataRef.current.prevActiveEl) {
dataRef.current.prevActiveEl.focus({ preventScroll: true });
}
}}
>
{(state) => (
<div
{...restProps}
ref={drawerRef}
className={getClassName(restProps.className, `${dPrefix}drawer`)}
style={{
...restProps.style,
...nestedStyle,
display: state === 'leaved' ? 'none' : undefined,
position: isFixed ? undefined : 'absolute',
zIndex,
}}
tabIndex={restProps.tabIndex ?? -1}
role="dialog"
aria-modal
aria-labelledby={headerNode ? titleId : undefined}
aria-describedby={bodyId}
onKeyDown={(e) => {
restProps.onKeyDown?.(e);
if (dEscClosable && checkNoExpandedEl(e.currentTarget) && e.code === 'Escape') {
changeVisible(false);
}
handleModalKeyDown(e);
}}
>
{dMask && (
<DMask
dVisible={visible}
onClose={() => {
if (dMaskClosable) {
changeVisible(false);
}
}}
/>
)}
<div
className={getClassName(`${dPrefix}drawer__content`, `${dPrefix}drawer__content--${dPlacement}`)}
style={{
width: dPlacement === 'left' || dPlacement === 'right' ? dWidth : undefined,
height: dPlacement === 'bottom' || dPlacement === 'top' ? dHeight : undefined,
...transitionStyles[state],
}}
>
{headerNode}
<div id={bodyId} className={`${dPrefix}drawer__body`}>
{children}
</div>
{dFooter &&
React.cloneElement<DDrawerFooterPrivateProps>(dFooter, {
__onClose: () => {
changeVisible(false);
},
})}
</div>
</div>
)}
</DTransition>
{childDrawer}
</>
);
return dContainer === false ? drawerNode : containerRef.current && ReactDOM.createPortal(drawerNode, containerRef.current);
};
DDrawer.Header = DDrawerHeader;
DDrawer.Footer = DDrawerFooter;