UNPKG

communication-react-19

Version:

React library for building modern communication user experiences utilizing Azure Communication Services (React 19 compatible fork)

682 lines 30.4 kB
// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as React from 'react'; import { useBoolean, useMergedRefs, useConst, useSetTimeout, useId, useUnmount } from '@fluentui/react-hooks'; import { allowOverscrollOnElement, allowScrollOnElement, AnimationVariables, classNamesFunction, DirectionalHint, elementContains, EventGroup, FocusTrapZone, getGlobalClassNames, getPropsWithDefaults, Icon, KeyCodes, Layer, memoizeFunction, mergeStyles, on, Overlay, Popup, ResponsiveMode, styled, useResponsiveMode, ZIndexes } from '@fluentui/react'; const animationDuration = AnimationVariables.durationValue2; const ZERO = { x: 0, y: 0 }; const DEFAULT_PROPS = { isOpen: false, isDarkOverlay: true, className: '', containerClassName: '', enableAriaHiddenSiblings: true }; const getModalClassNames = classNamesFunction(); const getMoveDelta = (ev) => { let delta = 10; if (ev.shiftKey) { if (!ev.ctrlKey) { delta = 50; } } else if (ev.ctrlKey) { delta = 1; } return delta; }; const useComponentRef = (props, focusTrapZone) => { React.useImperativeHandle(props.componentRef, () => ({ focus() { if (focusTrapZone.current) { focusTrapZone.current.focus(); } } }), [focusTrapZone]); }; const ModalBase = React.forwardRef((propsWithoutDefaults, ref) => { var _a; const props = getPropsWithDefaults(DEFAULT_PROPS, propsWithoutDefaults); const { allowTouchBodyScroll, className, children, containerClassName, scrollableContentClassName, elementToFocusOnDismiss, firstFocusableSelector, forceFocusInsideTrap, ignoreExternalFocusing, isBlocking, isAlert, isClickableOutsideFocusTrap, isDarkOverlay, onDismiss, layerProps, overlay, isOpen, titleAriaId, styles, subtitleAriaId, theme, topOffsetFixed, responsiveMode, onLayerDidMount, isModeless, dragOptions, onDismissed, minDragPosition, maxDragPosition, dataUiId, keyEventElement = window } = props; const rootRef = React.useRef(null); const focusTrapZone = React.useRef(null); const focusTrapZoneElm = React.useRef(null); const mergedRef = useMergedRefs(rootRef, ref); const modalResponsiveMode = useResponsiveMode(mergedRef); const focusTrapZoneId = useId('ModalFocusTrapZone'); const { setTimeout, clearTimeout } = useSetTimeout(); const [isModalOpen, setIsModalOpen] = React.useState(isOpen); const [isVisible, setIsVisible] = React.useState(isOpen); const [coordinates, setCoordinates] = React.useState(ZERO); const [modalRectangleTop, setModalRectangleTop] = React.useState(); const [isModalMenuOpen, { setTrue: setModalMenuOpen, setFalse: setModalMenuClose }] = useBoolean(false); const [_, forceUpdate] = React.useState(0); const forceUpdateCallback = React.useCallback(() => forceUpdate((prev) => prev + 1), []); const internalState = useConst(() => ({ onModalCloseTimer: 0, allowTouchBodyScroll, scrollableContent: null, lastSetCoordinates: ZERO, events: new EventGroup({}) })); const { keepInBounds } = dragOptions || {}; const isAlertRole = isAlert !== null && isAlert !== void 0 ? isAlert : (isBlocking && !isModeless); const layerClassName = layerProps === undefined ? '' : layerProps.className; const classNames = getModalClassNames(styles, { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion theme: theme, className, containerClassName, scrollableContentClassName, isOpen, isVisible, hasBeenOpened: internalState.hasBeenOpened, modalRectangleTop, topOffsetFixed, isModeless, layerClassName, windowInnerHeight: window === null || window === void 0 ? void 0 : window.innerHeight, isDefaultDragHandle: dragOptions && !dragOptions.dragHandleSelector }); const mergedLayerProps = Object.assign(Object.assign({ eventBubblingEnabled: false }, layerProps), { onLayerDidMount: layerProps && layerProps.onLayerDidMount ? layerProps.onLayerDidMount : onLayerDidMount, insertFirst: isModeless, className: classNames.layer }); // Allow the user to scroll within the modal but not on the body const allowScrollOnModal = React.useCallback((elt) => { if (elt) { if (internalState.allowTouchBodyScroll) { allowOverscrollOnElement(elt, internalState.events); } else { allowScrollOnElement(elt, internalState.events); } } else { internalState.events.off(internalState.scrollableContent); } internalState.scrollableContent = elt; }, [internalState]); const registerInitialModalPosition = () => { const dialogMain = focusTrapZoneElm.current; const modalRectangle = dialogMain === null || dialogMain === void 0 ? void 0 : dialogMain.getBoundingClientRect(); if (modalRectangle) { if (topOffsetFixed) { setModalRectangleTop(modalRectangle.top); } if (keepInBounds) { // x/y are unavailable in IE, so use the equivalent left/top internalState.minPosition = minDragPosition !== null && minDragPosition !== void 0 ? minDragPosition : { x: -modalRectangle.left, y: -modalRectangle.top }; internalState.maxPosition = maxDragPosition !== null && maxDragPosition !== void 0 ? maxDragPosition : { x: modalRectangle.left, y: modalRectangle.top }; // Make sure the initial co-ordinates are within clamp bounds. setCoordinates({ x: getClampedAxis('x', coordinates.x), y: getClampedAxis('y', coordinates.y) }); } } }; /** * Clamps an axis to a specified min and max position. * * @param axis A string that represents the axis (x/y). * @param position The position on the axis. */ const getClampedAxis = React.useCallback((axis, position) => { const { minPosition, maxPosition } = internalState; if (keepInBounds && minPosition && maxPosition) { position = Math.max(minPosition[axis], position); position = Math.min(maxPosition[axis], position); } return position; }, [keepInBounds, internalState]); const handleModalClose = () => { var _a; internalState.lastSetCoordinates = ZERO; setModalMenuClose(); internalState.isInKeyboardMoveMode = false; setIsModalOpen(false); setCoordinates(ZERO); (_a = internalState.disposeOnKeyUp) === null || _a === void 0 ? void 0 : _a.call(internalState); onDismissed === null || onDismissed === void 0 ? void 0 : onDismissed(); }; const handleDragStart = React.useCallback(() => { setModalMenuClose(); internalState.isInKeyboardMoveMode = false; }, [internalState, setModalMenuClose]); const handleDrag = React.useCallback((ev, dragData) => { setCoordinates((prevValue) => ({ x: getClampedAxis('x', prevValue.x + dragData.delta.x), y: getClampedAxis('y', prevValue.y + dragData.delta.y) })); }, [getClampedAxis]); const handleDragStop = React.useCallback(() => { if (focusTrapZone.current) { focusTrapZone.current.focus(); } }, []); const handleEnterKeyboardMoveMode = () => { // We need a global handleKeyDown event when we are in the move mode so that we can // handle the key presses and the components inside the modal do not get the events const handleKeyDown = (event) => { const ev = event; if (ev.altKey && ev.ctrlKey && ev.keyCode === KeyCodes.space) { // CTRL + ALT + SPACE is handled during keyUp ev.preventDefault(); ev.stopPropagation(); return; } const newLocal = ev.altKey || ev.keyCode === KeyCodes.escape; if (isModalMenuOpen && newLocal) { setModalMenuClose(); } if (internalState.isInKeyboardMoveMode && (ev.keyCode === KeyCodes.escape || ev.keyCode === KeyCodes.enter)) { internalState.isInKeyboardMoveMode = false; forceUpdateCallback(); ev.preventDefault(); ev.stopPropagation(); } if (internalState.isInKeyboardMoveMode) { let handledEvent = true; const delta = getMoveDelta(ev); switch (ev.keyCode) { /* eslint-disable no-fallthrough */ case KeyCodes.escape: setCoordinates(internalState.lastSetCoordinates); case KeyCodes.enter: { // TODO: determine if fallthrough was intentional /* eslint-enable no-fallthrough */ internalState.lastSetCoordinates = ZERO; // setIsInKeyboardMoveMode(false); break; } case KeyCodes.up: { setCoordinates((prevValue) => ({ x: prevValue.x, y: getClampedAxis('y', prevValue.y - delta) })); break; } case KeyCodes.down: { setCoordinates((prevValue) => ({ x: prevValue.x, y: getClampedAxis('y', prevValue.y + delta) })); break; } case KeyCodes.left: { setCoordinates((prevValue) => ({ x: getClampedAxis('x', prevValue.x - delta), y: prevValue.y })); break; } case KeyCodes.right: { setCoordinates((prevValue) => ({ x: getClampedAxis('x', prevValue.x + delta), y: prevValue.y })); break; } default: { handledEvent = false; } } if (handledEvent) { ev.preventDefault(); ev.stopPropagation(); } } }; internalState.lastSetCoordinates = coordinates; setModalMenuClose(); internalState.isInKeyboardMoveMode = true; keyEventElement.addEventListener('keydown', handleKeyDown, true /* useCapture */); internalState.disposeOnKeyDown = () => { keyEventElement.removeEventListener('keydown', handleKeyDown, true /* useCapture */); internalState.disposeOnKeyDown = undefined; }; }; const handleExitKeyboardMoveMode = () => { var _a; internalState.lastSetCoordinates = ZERO; internalState.isInKeyboardMoveMode = false; (_a = internalState.disposeOnKeyDown) === null || _a === void 0 ? void 0 : _a.call(internalState); }; const registerForKeyUp = () => { const handleKeyUp = (event) => { const ev = event; // Needs to handle the CTRL + ALT + SPACE key during keyup due to FireFox bug: // https://bugzilla.mozilla.org/show_bug.cgi?id=1220143 if (ev.altKey && ev.ctrlKey && ev.keyCode === KeyCodes.space) { if (elementContains(internalState.scrollableContent, ev.target)) { setModalMenuOpen(); ev.preventDefault(); ev.stopPropagation(); } } }; if (!internalState.disposeOnKeyUp) { keyEventElement.addEventListener('keyup', handleKeyUp, true /* useCapture */); internalState.disposeOnKeyUp = () => { keyEventElement.removeEventListener('keyup', handleKeyUp, true /* useCapture */); internalState.disposeOnKeyUp = undefined; }; } }; React.useEffect(() => { clearTimeout(internalState.onModalCloseTimer); // Opening the dialog if (isOpen) { // This must be done after the modal content has rendered requestAnimationFrame(() => setTimeout(registerInitialModalPosition, 0)); setIsModalOpen(true); // Add a keyUp handler for all key up events once the dialog is open. if (dragOptions) { registerForKeyUp(); } internalState.hasBeenOpened = true; setIsVisible(true); } // Closing the dialog if (!isOpen && isModalOpen) { internalState.onModalCloseTimer = setTimeout(handleModalClose, parseFloat(animationDuration) * 1000); setIsVisible(false); } // eslint-disable-next-line react-hooks/exhaustive-deps -- should only run if isModalOpen or isOpen mutates or if min/max drag bounds are updated. }, [isModalOpen, isOpen, minDragPosition, maxDragPosition]); useUnmount(() => { internalState.events.dispose(); }); useComponentRef(props, focusTrapZone); const modalContent = (React.createElement(FocusTrapZone, Object.assign({ disabled: true, id: focusTrapZoneId, ref: focusTrapZoneElm, componentRef: focusTrapZone, className: classNames.main, elementToFocusOnDismiss: elementToFocusOnDismiss, isClickableOutsideFocusTrap: isModeless || isClickableOutsideFocusTrap || !isBlocking, ignoreExternalFocusing: ignoreExternalFocusing, forceFocusInsideTrap: forceFocusInsideTrap && !isModeless, firstFocusableSelector: firstFocusableSelector, focusPreviouslyFocusedInnerElement: true, onBlur: internalState.isInKeyboardMoveMode ? handleExitKeyboardMoveMode : undefined, "data-ui-id": dataUiId }, ((_a = props.focusTrapZoneProps) !== null && _a !== void 0 ? _a : {})), dragOptions && internalState.isInKeyboardMoveMode && (React.createElement("div", { className: classNames.keyboardMoveIconContainer }, dragOptions.keyboardMoveIconProps ? (React.createElement(Icon, Object.assign({}, dragOptions.keyboardMoveIconProps))) : (React.createElement(Icon, { iconName: "move", className: classNames.keyboardMoveIcon })))), React.createElement("div", { ref: allowScrollOnModal, className: classNames.scrollableContent, "data-is-scrollable": true }, dragOptions && isModalMenuOpen && (React.createElement(dragOptions.menu, { items: [ { key: 'move', text: dragOptions.moveMenuItemText, onClick: handleEnterKeyboardMoveMode }, { key: 'close', text: dragOptions.closeMenuItemText, onClick: handleModalClose } ], onDismiss: setModalMenuClose, alignTargetEdge: true, coverTarget: true, directionalHint: DirectionalHint.topLeftEdge, directionalHintFixed: true, shouldFocusOnMount: true, target: internalState.scrollableContent })), children))); return ( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion (isModalOpen && modalResponsiveMode >= (responsiveMode || ResponsiveMode.small) && (React.createElement(Layer, Object.assign({ ref: mergedRef }, mergedLayerProps), React.createElement(Popup, { role: isAlertRole ? 'alertdialog' : 'dialog', ariaLabelledBy: titleAriaId, ariaDescribedBy: subtitleAriaId, // onDismiss={onDismiss} shouldRestoreFocus: !ignoreExternalFocusing, "aria-modal": !isModeless }, React.createElement("div", { className: classNames.root, role: !isModeless ? 'document' : undefined }, !isModeless && (React.createElement(Overlay, Object.assign({ "aria-hidden": true, isDarkThemed: isDarkOverlay, onClick: isBlocking ? undefined : onDismiss, allowTouchBodyScroll: allowTouchBodyScroll }, overlay))), dragOptions ? (React.createElement(DraggableZone, { handleSelector: dragOptions.dragHandleSelector || `#${focusTrapZoneId}`, preventDragSelector: "button", onStart: handleDragStart, onDragChange: handleDrag, onStop: handleDragStop, position: coordinates }, modalContent)) : (modalContent)))))) || null); }); ModalBase.displayName = 'ModalBase'; const getDraggableZoneClassNames = memoizeFunction((className, isDragging) => { return { root: mergeStyles(className, isDragging && { touchAction: 'none', selectors: { '& *': { userSelect: 'none' } } }) }; }); const eventMapping = { touch: { start: 'touchstart', move: 'touchmove', stop: 'touchend' }, mouse: { start: 'mousedown', move: 'mousemove', stop: 'mouseup' } }; class DraggableZone extends React.Component { constructor(props) { super(props); this._currentEventType = eventMapping.mouse; this._events = []; this._onMouseDown = (event) => { const onMouseDown = React.Children.only(this.props.children).props.onMouseDown; if (onMouseDown) { onMouseDown(event); } this._currentEventType = eventMapping.mouse; return this._onDragStart(event); }; this._onMouseUp = (event) => { const onMouseUp = React.Children.only(this.props.children).props.onMouseUp; if (onMouseUp) { onMouseUp(event); } this._currentEventType = eventMapping.mouse; return this._onDragStop(event); }; this._onTouchStart = (event) => { const onTouchStart = React.Children.only(this.props.children).props.onTouchStart; if (onTouchStart) { onTouchStart(event); } this._currentEventType = eventMapping.touch; return this._onDragStart(event); }; this._onTouchEnd = (event) => { const onTouchEnd = React.Children.only(this.props.children).props.onTouchEnd; if (onTouchEnd) { onTouchEnd(event); } this._currentEventType = eventMapping.touch; this._onDragStop(event); }; this._onDragStart = (event) => { // Only handle left click for dragging if (typeof event.button === 'number' && event.button !== 0) { return false; } // If the target doesn't match the handleSelector OR // if the target does match the preventDragSelector, bail out if ((this.props.handleSelector && !this._matchesSelector(event.target, this.props.handleSelector)) || (this.props.preventDragSelector && this._matchesSelector(event.target, this.props.preventDragSelector))) { return; } // Remember the touch identifier if this is a touch event so we can // distinguish between individual touches in multitouch scenarios // by remembering which touch point we were given this._touchId = this._getTouchId(event); const position = this._getControlPosition(event); if (position === undefined) { return; } const dragData = this._createDragDataFromPosition(position); this.props.onStart && this.props.onStart(event, dragData); this.setState({ isDragging: true, lastPosition: position }); // hook up the appropriate mouse/touch events to the body to ensure // smooth dragging this._events = [ on(document.body, this._currentEventType.move, this._onDrag, true /* use capture phase */), on(document.body, this._currentEventType.stop, this._onDragStop, true /* use capture phase */) ]; return; }; this._onDrag = (event) => { // Prevent scrolling on mobile devices if (event.type === 'touchmove') { event.preventDefault(); } const position = this._getControlPosition(event); if (!position) { return; } // create the updated drag data from the position data const updatedData = this._createUpdatedDragData(this._createDragDataFromPosition(position)); const updatedPosition = updatedData.position; this.props.onDragChange && this.props.onDragChange(event, updatedData); this.setState({ position: updatedPosition, lastPosition: position }); }; this._onDragStop = (event) => { if (!this.state.isDragging) { return; } const position = this._getControlPosition(event); if (!position) { return; } const baseDragData = this._createDragDataFromPosition(position); // Set dragging to false and reset the lastPosition this.setState({ isDragging: false, lastPosition: undefined }); this.props.onStop && this.props.onStop(event, baseDragData); if (this.props.position) { this.setState({ position: this.props.position }); } // Remove event handlers this._events.forEach((dispose) => dispose()); }; this.state = { isDragging: false, position: this.props.position || { x: 0, y: 0 }, lastPosition: undefined }; } componentDidUpdate(prevProps) { if (this.props.position && (!prevProps.position || this.props.position !== prevProps.position)) { this.setState({ position: this.props.position }); } } componentWillUnmount() { this._events.forEach((dispose) => dispose()); } render() { const child = React.Children.only(this.props.children); const { props } = child; const { position } = this.props; const { position: statePosition, isDragging } = this.state; let x = statePosition.x; let y = statePosition.y; if (position && !isDragging) { x = position.x; y = position.y; } return React.cloneElement(child, { style: Object.assign(Object.assign({}, props.style), { transform: `translate(${x}px, ${y}px)` }), className: getDraggableZoneClassNames(props.className, this.state.isDragging).root, onMouseDown: this._onMouseDown, onMouseUp: this._onMouseUp, onTouchStart: this._onTouchStart, onTouchEnd: this._onTouchEnd }); } /** * Get the control position based off the event that fired * @param event - The event to get offsets from */ _getControlPosition(event) { const touchObj = this._getActiveTouch(event); // did we get the right touch? if (this._touchId !== undefined && !touchObj) { return undefined; } const eventToGetOffset = touchObj || event; return { x: eventToGetOffset.clientX, y: eventToGetOffset.clientY }; } /** * Get the active touch point that we have saved from the event's TouchList * @param event - The event used to get the TouchList for the active touch point */ _getActiveTouch(event) { return ((event.targetTouches && this._findTouchInTouchList(event.targetTouches)) || (event.changedTouches && this._findTouchInTouchList(event.changedTouches))); } /** * Get the initial touch identifier associated with the given event * @param event - The event that contains the TouchList */ _getTouchId(event) { const touch = (event.targetTouches && event.targetTouches[0]) || (event.changedTouches && event.changedTouches[0]); if (touch) { return touch.identifier; } return; } /** * Returns if an element (or any of the element's parents) match the given selector */ _matchesSelector(element, selector) { if (!element || element === document.body) { return false; } // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type const matchesSelectorFn = element.matches || element.webkitMatchesSelector || element.msMatchesSelector; /* for IE */ if (!matchesSelectorFn) { return false; } return matchesSelectorFn.call(element, selector) || this._matchesSelector(element.parentElement, selector); } /** * Attempts to find the Touch that matches the identifier we stored in dragStart * @param touchList The TouchList to look for the stored identifier from dragStart */ _findTouchInTouchList(touchList) { var _a; if (this._touchId === undefined) { return; } for (let i = 0; i < touchList.length; i++) { if (((_a = touchList[i]) === null || _a === void 0 ? void 0 : _a.identifier) === this._touchId) { return touchList[i]; } } return undefined; } /** * Create DragData based off of the last known position and the new position passed in * @param position The new position as part of the drag */ _createDragDataFromPosition(position) { const { lastPosition } = this.state; // If we have no lastPosition, use the given position // for last position if (lastPosition === undefined) { return { delta: { x: 0, y: 0 }, lastPosition: position, position }; } return { delta: { x: position.x - lastPosition.x, y: position.y - lastPosition.y }, lastPosition, position }; } /** * Creates an updated DragData based off the current position and given baseDragData * @param baseDragData The base DragData (from _createDragDataFromPosition) used to calculate the updated positions */ _createUpdatedDragData(baseDragData) { const { position } = this.state; return { position: { x: position.x + baseDragData.delta.x, y: position.y + baseDragData.delta.y }, delta: baseDragData.delta, lastPosition: position }; } } const globalClassNames = { root: 'ms-Modal', main: 'ms-Dialog-main', scrollableContent: 'ms-Modal-scrollableContent', isOpen: 'is-open', layer: 'ms-Modal-Layer' }; const getStyles = (props) => { const { className, containerClassName, scrollableContentClassName, isOpen, isVisible, hasBeenOpened, modalRectangleTop, theme, topOffsetFixed, isModeless, layerClassName, isDefaultDragHandle, windowInnerHeight } = props; const { palette, effects, fonts } = theme; const classNames = getGlobalClassNames(globalClassNames, theme); return { root: [ classNames.root, fonts.medium, { backgroundColor: 'transparent', position: isModeless ? 'absolute' : 'fixed', height: '100%', width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: 0, pointerEvents: 'none', transition: `opacity ${animationDuration}` }, topOffsetFixed && typeof modalRectangleTop === 'number' && hasBeenOpened && { alignItems: 'flex-start' }, isOpen && classNames.isOpen, isVisible && { opacity: 1, pointerEvents: 'auto' }, className ], main: [ classNames.main, { boxShadow: effects.elevation64, borderRadius: effects.roundedCorner2, backgroundColor: palette.white, boxSizing: 'border-box', position: 'relative', textAlign: 'left', outline: '3px solid transparent', maxHeight: 'calc(100% - 32px)', maxWidth: 'calc(100% - 32px)', minHeight: '176px', minWidth: '288px', overflowY: 'auto', zIndex: isModeless ? ZIndexes.Layer : undefined }, topOffsetFixed && typeof modalRectangleTop === 'number' && hasBeenOpened && { top: modalRectangleTop }, isDefaultDragHandle && { cursor: 'move' }, containerClassName ], scrollableContent: [ classNames.scrollableContent, { overflowY: 'auto', flexGrow: 1, maxHeight: '100vh', selectors: { ['@supports (-webkit-overflow-scrolling: touch)']: { maxHeight: windowInnerHeight } } }, scrollableContentClassName ], layer: isModeless && [ layerClassName, classNames.layer, { position: 'static', width: 'unset', height: 'unset' } ], keyboardMoveIconContainer: { position: 'absolute', display: 'flex', justifyContent: 'center', width: '100%', padding: '3px 0px' }, keyboardMoveIcon: { fontSize: fonts.xLargePlus.fontSize, width: '24px' } }; }; /** @internal */ export const _ModalClone = styled(ModalBase, getStyles, undefined, { scope: 'Modal', fields: ['theme', 'styles', 'enableAriaHiddenSiblings'] }); _ModalClone.displayName = 'Modal'; //# sourceMappingURL=ModalClone.js.map