import * as React from 'react'; import styles from '@patternfly/react-styles/css/components/Drawer/drawer'; import { css } from '@patternfly/react-styles'; import { DrawerColorVariant, DrawerContext } from './Drawer'; import { formatBreakpointMods, getLanguageDirection } from '../../helpers/util'; import { GenerateId } from '../../helpers/GenerateId/GenerateId'; import { FocusTrap } from '../../helpers/FocusTrap/FocusTrap'; import cssPanelMdFlexBasis from '@patternfly/react-tokens/dist/esm/c_drawer__panel_md_FlexBasis'; import cssPanelMdFlexBasisMin from '@patternfly/react-tokens/dist/esm/c_drawer__panel_md_FlexBasis_min'; import cssPanelMdFlexBasisMax from '@patternfly/react-tokens/dist/esm/c_drawer__panel_md_FlexBasis_max'; export interface DrawerPanelFocusTrapObject { /** Enables a focus trap on the drawer panel content. This will also automatically * handle focus management when the panel expands and when it collapses. Do not pass * this prop if the isStatic prop on the drawer component is true. */ enabled?: boolean; /** The element to focus when the drawer panel content expands. By default the * first focusable element will receive focus. If there are no focusable elements, the * panel itself will receive focus. */ elementToFocusOnExpand?: HTMLElement | SVGElement | string; /** One or more id's to use for the drawer panel content's accessible label. */ 'aria-labelledby'?: string; } export interface DrawerPanelContentProps extends Omit, 'onResize'> { /** Additional classes added to the drawer. */ className?: string; /** ID of the drawer panel */ id?: string; /** Content to be rendered in the drawer panel. */ children?: React.ReactNode; /** Flag indicating that the drawer panel should not have a border. */ hasNoBorder?: boolean; /** Flag indicating that the drawer panel should be resizable. */ isResizable?: boolean; /** Callback for resize end. */ onResize?: (event: MouseEvent | TouchEvent | React.KeyboardEvent, width: number, id: string) => void; /** The minimum size of a drawer, in either pixels or percentage. */ minSize?: string; /** The starting size of a resizable drawer, in either pixels or percentage. */ defaultSize?: string; /** The maximum size of a drawer, in either pixels or percentage. */ maxSize?: string; /** The increment amount for keyboard drawer resizing, in pixels. */ increment?: number; /** Aria label for the resizable drawer splitter. */ resizeAriaLabel?: string; /** Width for drawer panel at various breakpoints. Overriden by resizable drawer minSize and defaultSize. */ widths?: { default?: 'width_25' | 'width_33' | 'width_50' | 'width_66' | 'width_75' | 'width_100'; lg?: 'width_25' | 'width_33' | 'width_50' | 'width_66' | 'width_75' | 'width_100'; xl?: 'width_25' | 'width_33' | 'width_50' | 'width_66' | 'width_75' | 'width_100'; '2xl'?: 'width_25' | 'width_33' | 'width_50' | 'width_66' | 'width_75' | 'width_100'; }; /** Color variant of the background of the drawer panel */ colorVariant?: DrawerColorVariant | 'light-200' | 'no-background' | 'default'; /** Adds and customizes a focus trap on the drawer panel content. */ focusTrap?: DrawerPanelFocusTrapObject; } let isResizing: boolean = null; let newSize: number = 0; export const DrawerPanelContent: React.FunctionComponent = ({ className = '', id, children, hasNoBorder = false, isResizable = false, onResize, minSize, defaultSize, maxSize, increment = 5, resizeAriaLabel = 'Resize', widths, colorVariant = DrawerColorVariant.default, focusTrap, ...props }: DrawerPanelContentProps) => { const panel = React.useRef(); const splitterRef = React.useRef(); const [separatorValue, setSeparatorValue] = React.useState(0); const { position, isExpanded, isStatic, onExpand, drawerRef, drawerContentRef, isInline } = React.useContext(DrawerContext); const hidden = isStatic ? false : !isExpanded; const [isExpandedInternal, setIsExpandedInternal] = React.useState(!hidden); const [isFocusTrapActive, setIsFocusTrapActive] = React.useState(false); const previouslyFocusedElement = React.useRef(null); let currWidth: number = 0; let panelRect: DOMRect; let end: number; let start: number; let bottom: number; let setInitialVals: boolean = true; if (isStatic && focusTrap?.enabled) { // eslint-disable-next-line no-console console.warn( `DrawerPanelContent: The focusTrap.enabled prop cannot be true if the Drawer's isStatic prop is true. This will cause a permanent focus trap.` ); } React.useEffect(() => { if (!isStatic && isExpanded) { setIsExpandedInternal(isExpanded); } }, [isStatic, isExpanded]); const calcValueNow = () => { let splitterPos; let drawerSize; const isRTL = getLanguageDirection(panel.current) === 'rtl'; if (isInline && (position === 'end' || position === 'right')) { if (isRTL) { splitterPos = panel.current.getBoundingClientRect().left - splitterRef.current.getBoundingClientRect().right; drawerSize = drawerRef.current.getBoundingClientRect().left - drawerRef.current.getBoundingClientRect().right; } else { splitterPos = panel.current.getBoundingClientRect().right - splitterRef.current.getBoundingClientRect().left; drawerSize = drawerRef.current.getBoundingClientRect().right - drawerRef.current.getBoundingClientRect().left; } } else if (isInline && (position === 'start' || position === 'left')) { if (isRTL) { splitterPos = splitterRef.current.getBoundingClientRect().left - panel.current.getBoundingClientRect().right; drawerSize = drawerRef.current.getBoundingClientRect().left - drawerRef.current.getBoundingClientRect().right; } else { splitterPos = splitterRef.current.getBoundingClientRect().right - panel.current.getBoundingClientRect().left; drawerSize = drawerRef.current.getBoundingClientRect().right - drawerRef.current.getBoundingClientRect().left; } } else if (position === 'end' || position === 'right') { if (isRTL) { splitterPos = drawerContentRef.current.getBoundingClientRect().left - splitterRef.current.getBoundingClientRect().right; drawerSize = drawerContentRef.current.getBoundingClientRect().left - drawerContentRef.current.getBoundingClientRect().right; } else { splitterPos = drawerContentRef.current.getBoundingClientRect().right - splitterRef.current.getBoundingClientRect().left; drawerSize = drawerContentRef.current.getBoundingClientRect().right - drawerContentRef.current.getBoundingClientRect().left; } } else if (position === 'start' || position === 'left') { if (isRTL) { splitterPos = splitterRef.current.getBoundingClientRect().left - drawerContentRef.current.getBoundingClientRect().right; drawerSize = drawerContentRef.current.getBoundingClientRect().left - drawerContentRef.current.getBoundingClientRect().right; } else { splitterPos = splitterRef.current.getBoundingClientRect().right - drawerContentRef.current.getBoundingClientRect().left; drawerSize = drawerContentRef.current.getBoundingClientRect().right - drawerContentRef.current.getBoundingClientRect().left; } } else if (position === 'bottom') { splitterPos = drawerContentRef.current.getBoundingClientRect().bottom - splitterRef.current.getBoundingClientRect().top; drawerSize = drawerContentRef.current.getBoundingClientRect().bottom - drawerContentRef.current.getBoundingClientRect().top; } const newSplitterPos = (splitterPos / drawerSize) * 100; return Math.round((newSplitterPos + Number.EPSILON) * 100) / 100; }; const handleTouchStart = (e: React.TouchEvent) => { e.stopPropagation(); document.addEventListener('touchmove', callbackTouchMove, { passive: false }); document.addEventListener('touchend', callbackTouchEnd); isResizing = true; }; const handleMousedown = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); document.addEventListener('mousemove', callbackMouseMove); document.addEventListener('mouseup', callbackMouseUp); drawerRef.current.classList.add(css(styles.modifiers.resizing)); isResizing = true; setInitialVals = true; }; const handleMouseMove = (e: MouseEvent) => { const mousePos = position === 'bottom' ? e.clientY : e.clientX; handleControlMove(e, mousePos); }; const handleTouchMove = (e: TouchEvent) => { e.preventDefault(); e.stopImmediatePropagation(); const touchPos = position === 'bottom' ? e.touches[0].clientY : e.touches[0].clientX; handleControlMove(e, touchPos); }; const handleControlMove = (e: MouseEvent | TouchEvent, controlPosition: number) => { const isRTL = getLanguageDirection(panel.current) === 'rtl'; e.stopPropagation(); if (!isResizing) { return; } if (setInitialVals) { panelRect = panel.current.getBoundingClientRect(); if (isRTL) { start = panelRect.right; end = panelRect.left; } else { end = panelRect.right; start = panelRect.left; } bottom = panelRect.bottom; setInitialVals = false; } const mousePos = controlPosition; let newSize = 0; if (position === 'end' || position === 'right') { newSize = isRTL ? mousePos - end : end - mousePos; } else if (position === 'start' || position === 'left') { newSize = isRTL ? start - mousePos : mousePos - start; } else { newSize = bottom - mousePos; } if (position === 'bottom') { panel.current.style.overflowAnchor = 'none'; } panel.current.style.setProperty(cssPanelMdFlexBasis.name, newSize + 'px'); currWidth = newSize; setSeparatorValue(calcValueNow()); }; const handleMouseup = (e: MouseEvent) => { if (!isResizing) { return; } drawerRef.current.classList.remove(css(styles.modifiers.resizing)); isResizing = false; onResize && onResize(e, currWidth, id); setInitialVals = true; document.removeEventListener('mousemove', callbackMouseMove); document.removeEventListener('mouseup', callbackMouseUp); }; const handleTouchEnd = (e: TouchEvent) => { e.stopPropagation(); if (!isResizing) { return; } isResizing = false; onResize && onResize(e, currWidth, id); document.removeEventListener('touchmove', callbackTouchMove); document.removeEventListener('touchend', callbackTouchEnd); }; const callbackMouseMove = React.useCallback(handleMouseMove, []); const callbackTouchEnd = React.useCallback(handleTouchEnd, []); const callbackTouchMove = React.useCallback(handleTouchMove, []); const callbackMouseUp = React.useCallback(handleMouseup, []); const handleKeys = (e: React.KeyboardEvent) => { const isRTL = getLanguageDirection(panel.current) === 'rtl'; const key = e.key; if ( key !== 'Escape' && key !== 'Enter' && key !== 'ArrowUp' && key !== 'ArrowDown' && key !== 'ArrowLeft' && key !== 'ArrowRight' ) { if (isResizing) { e.preventDefault(); } return; } e.preventDefault(); if (key === 'Escape' || key === 'Enter') { onResize && onResize(e, currWidth, id); } const panelRect = panel.current.getBoundingClientRect(); newSize = position === 'bottom' ? panelRect.height : panelRect.width; let delta = 0; if (key === 'ArrowRight') { if (isRTL) { delta = position === 'left' || position === 'start' ? -increment : increment; } else { delta = position === 'left' || position === 'start' ? increment : -increment; } } else if (key === 'ArrowLeft') { if (isRTL) { delta = position === 'left' || position === 'start' ? increment : -increment; } else { delta = position === 'left' || position === 'start' ? -increment : increment; } } else if (key === 'ArrowUp') { delta = increment; } else if (key === 'ArrowDown') { delta = -increment; } newSize = newSize + delta; if (position === 'bottom') { panel.current.style.overflowAnchor = 'none'; } panel.current.style.setProperty(cssPanelMdFlexBasis.name, newSize + 'px'); currWidth = newSize; setSeparatorValue(calcValueNow()); }; const boundaryCssVars: any = {}; if (defaultSize) { boundaryCssVars[cssPanelMdFlexBasis.name] = defaultSize; } if (minSize) { boundaryCssVars[cssPanelMdFlexBasisMin.name] = minSize; } if (maxSize) { boundaryCssVars[cssPanelMdFlexBasisMax.name] = maxSize; } const isValidFocusTrap = focusTrap?.enabled && !isStatic; const Component = isValidFocusTrap ? FocusTrap : 'div'; return ( {(panelId) => { const focusTrapProps = { tabIndex: -1, 'aria-modal': true, role: 'dialog', active: isFocusTrapActive, 'aria-labelledby': focusTrap?.['aria-labelledby'] || id || panelId, focusTrapOptions: { fallbackFocus: () => panel.current, onActivate: () => { if (previouslyFocusedElement.current !== document.activeElement) { previouslyFocusedElement.current = document.activeElement; } }, onDeactivate: () => { previouslyFocusedElement.current && previouslyFocusedElement.current.focus && previouslyFocusedElement.current.focus(); }, clickOutsideDeactivates: true, returnFocusOnDeactivate: false, // FocusTrap's initialFocus can accept false as a value to prevent initial focus. // We want to prevent this in case false is ever passed in. initialFocus: focusTrap?.elementToFocusOnExpand || undefined, escapeDeactivates: false } }; return ( { if ((ev.target as HTMLElement) === panel.current) { if (!hidden && ev.nativeEvent.propertyName === 'transform') { onExpand(ev); } setIsExpandedInternal(!hidden); if (isValidFocusTrap && ev.nativeEvent.propertyName === 'transform') { setIsFocusTrapActive((prevIsFocusTrapActive) => !prevIsFocusTrapActive); } } }} hidden={hidden} {...((defaultSize || minSize || maxSize) && { style: boundaryCssVars as React.CSSProperties })} {...props} ref={panel} > {isExpandedInternal && ( {isResizable && (
{children}
)} {!isResizable && children} )} ); }} ); }; DrawerPanelContent.displayName = 'DrawerPanelContent';