feat: Draggable and Resizable Modal (#16394)

* Implement resizable prop

* Implement resizableConfig

* Implement fluid Syntax Highlighter

* Implement draggable

* Destroy on close

* Implement draggableConfig

* Enhance with footer calculation

* Add new line

* Make whole header draggable trigger
This commit is contained in:
Geido 2021-08-25 16:11:16 +03:00 committed by GitHub
parent 93c60e4021
commit db11c3e6c8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 175 additions and 8 deletions

View File

@ -147,6 +147,7 @@
"react-dnd": "^11.1.3",
"react-dnd-html5-backend": "^11.1.3",
"react-dom": "^16.13.0",
"react-draggable": "^4.4.3",
"react-gravatar": "^2.6.1",
"react-hot-loader": "^4.12.20",
"react-js-cron": "^1.2.0",

View File

@ -34,6 +34,8 @@ InteractiveModal.args = {
primaryButtonType: 'danger',
show: true,
title: "I'm a modal!",
resizable: false,
draggable: false,
};
InteractiveModal.argTypes = {

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import React, { useRef, useState } from 'react';
import { isNil } from 'lodash';
import { styled, t } from '@superset-ui/core';
import { css } from '@emotion/react';
@ -25,6 +25,13 @@ import {
ModalProps as AntdModalProps,
} from 'src/common/components';
import Button from 'src/components/Button';
import { Resizable, ResizableProps } from 're-resizable';
import Draggable, {
DraggableBounds,
DraggableData,
DraggableEvent,
DraggableProps,
} from 'react-draggable';
export interface ModalProps {
className?: string;
@ -46,6 +53,10 @@ export interface ModalProps {
wrapProps?: object;
height?: string;
closable?: boolean;
resizable?: boolean;
resizableConfig?: ResizableProps;
draggable?: boolean;
draggableConfig?: DraggableProps;
destroyOnClose?: boolean;
}
@ -54,8 +65,19 @@ interface StyledModalProps {
responsive?: boolean;
height?: string;
hideFooter?: boolean;
draggable?: boolean;
resizable?: boolean;
}
const MODAL_HEADER_HEIGHT = 55;
const MODAL_MIN_CONTENT_HEIGHT = 54;
const MODAL_FOOTER_HEIGHT = 65;
const RESIZABLE_MIN_HEIGHT = MODAL_HEADER_HEIGHT + MODAL_MIN_CONTENT_HEIGHT;
const RESIZABLE_MIN_WIDTH = '380px';
const RESIZABLE_MAX_HEIGHT = '100vh';
const RESIZABLE_MAX_WIDTH = '100vw';
const BaseModal = (props: AntdModalProps) => (
// Removes mask animation. Fixed in 4.6.0.
// https://github.com/ant-design/ant-design/issues/27192
@ -101,9 +123,8 @@ export const StyledModal = styled(BaseModal)<StyledModalProps>`
.ant-modal-body {
padding: ${({ theme }) => theme.gridUnit * 4}px;
overflow: auto;
${({ height }) => height && `height: ${height};`}
${({ resizable, height }) => !resizable && height && `height: ${height};`}
}
.ant-modal-footer {
border-top: ${({ theme }) => theme.gridUnit / 4}px solid
${({ theme }) => theme.colors.grayscale.light2};
@ -129,6 +150,44 @@ export const StyledModal = styled(BaseModal)<StyledModalProps>`
&.no-content-padding .ant-modal-body {
padding: 0;
}
${({ draggable, theme }) =>
draggable &&
`
.ant-modal-header {
padding: 0;
.draggable-trigger {
cursor: move;
padding: ${theme.gridUnit * 4}px;
width: 100%;
}
}
`};
${({ resizable, hideFooter }) =>
resizable &&
`
.resizable {
pointer-events: all;
.resizable-wrapper {
height: 100%;
}
.ant-modal-content {
height: 100%;
.ant-modal-body {
/* 100% - header height - footer height */
height: ${
hideFooter
? `calc(100% - ${MODAL_HEADER_HEIGHT}px);`
: `calc(100% - ${MODAL_HEADER_HEIGHT}px - ${MODAL_FOOTER_HEIGHT}px);`
}
}
}
}
`}
`;
const CustomModal = ({
@ -148,8 +207,33 @@ const CustomModal = ({
footer,
hideFooter,
wrapProps,
draggable = false,
resizable = false,
resizableConfig = {
maxHeight: RESIZABLE_MAX_HEIGHT,
maxWidth: RESIZABLE_MAX_WIDTH,
minHeight: hideFooter
? RESIZABLE_MIN_HEIGHT
: RESIZABLE_MIN_HEIGHT + MODAL_FOOTER_HEIGHT,
minWidth: RESIZABLE_MIN_WIDTH,
enable: {
bottom: true,
bottomLeft: false,
bottomRight: true,
left: false,
top: false,
topLeft: false,
topRight: false,
right: true,
},
},
draggableConfig,
destroyOnClose,
...rest
}: ModalProps) => {
const draggableRef = useRef<HTMLDivElement>(null);
const [bounds, setBounds] = useState<DraggableBounds>();
const [dragDisabled, setDragDisabled] = useState<boolean>(true);
const modalFooter = isNil(footer)
? [
<Button key="back" onClick={onHide} cta data-test="modal-cancel-button">
@ -169,6 +253,35 @@ const CustomModal = ({
: footer;
const modalWidth = width || (responsive ? '100vw' : '600px');
const shouldShowMask = !(resizable || draggable);
const onDragStart = (_: DraggableEvent, uiData: DraggableData) => {
const { clientWidth, clientHeight } = window?.document?.documentElement;
const targetRect = draggableRef?.current?.getBoundingClientRect();
if (targetRect) {
setBounds({
left: -targetRect?.left + uiData?.x,
right: clientWidth - (targetRect?.right - uiData?.x),
top: -targetRect?.top + uiData?.y,
bottom: clientHeight - (targetRect?.bottom - uiData?.y),
});
}
};
const ModalTitle = () =>
draggable ? (
<div
className="draggable-trigger"
onMouseOver={() => dragDisabled && setDragDisabled(false)}
onMouseOut={() => !dragDisabled && setDragDisabled(true)}
>
{title}
</div>
) : (
<>{title}</>
);
return (
<StyledModal
centered={!!centered}
@ -178,14 +291,41 @@ const CustomModal = ({
maxWidth={maxWidth}
responsive={responsive}
visible={show}
title={title}
title={<ModalTitle />}
closeIcon={
<span className="close" aria-hidden="true">
×
</span>
}
footer={!hideFooter ? modalFooter : null}
hideFooter={hideFooter}
wrapProps={{ 'data-test': `${name || title}-modal`, ...wrapProps }}
modalRender={modal =>
resizable || draggable ? (
<Draggable
disabled={!draggable || dragDisabled}
bounds={bounds}
onStart={(event, uiData) => onDragStart(event, uiData)}
{...draggableConfig}
>
{resizable ? (
<Resizable className="resizable" {...resizableConfig}>
<div className="resizable-wrapper" ref={draggableRef}>
{modal}
</div>
</Resizable>
) : (
<div ref={draggableRef}>{modal}</div>
)}
</Draggable>
) : (
modal
)
}
mask={shouldShowMask}
draggable={draggable}
resizable={resizable}
destroyOnClose={destroyOnClose || resizable || draggable}
{...rest}
>
{children}

View File

@ -33,6 +33,8 @@ interface IModalTriggerProps {
width?: string;
maxWidth?: string;
responsive?: boolean;
draggable?: boolean;
resizable?: boolean;
}
export default {
@ -53,4 +55,6 @@ InteractiveModalTrigger.args = {
width: '600px',
maxWidth: '1000px',
responsive: true,
draggable: false,
resizable: false,
};

View File

@ -35,6 +35,10 @@ const propTypes = {
width: PropTypes.string,
maxWidth: PropTypes.string,
responsive: PropTypes.bool,
resizable: PropTypes.bool,
resizableConfig: PropTypes.object,
draggable: PropTypes.bool,
draggableConfig: PropTypes.object,
};
const defaultProps = {
@ -43,6 +47,8 @@ const defaultProps = {
isButton: false,
className: '',
modalTitle: '',
resizable: false,
draggable: false,
};
export default class ModalTrigger extends React.Component {
@ -79,6 +85,10 @@ export default class ModalTrigger extends React.Component {
width={this.props.width}
maxWidth={this.props.maxWidth}
responsive={this.props.responsive}
resizable={this.props.resizable}
resizableConfig={this.props.resizableConfig}
draggable={this.props.draggable}
draggableConfig={this.props.draggableConfig}
>
{this.props.modalBody}
</Modal>

View File

@ -91,6 +91,8 @@ const ExploreAdditionalActionsMenu = props => {
latestQueryFormData={props.latestQueryFormData}
/>
}
draggable
resizable
responsive
/>
</Menu.Item>

View File

@ -50,6 +50,14 @@ type Result = {
language: string;
};
const StyledSyntaxContainer = styled.div`
height: 100%;
`;
const StyledSyntaxHighlighter = styled(SyntaxHighlighter)`
height: calc(100% - 26px); // 100% - clipboard height
`;
const ViewQueryModal: React.FC<Props> = props => {
const [result, setResult] = useState<Result[]>([]);
const [isLoading, setIsLoading] = useState(false);
@ -93,7 +101,7 @@ const ViewQueryModal: React.FC<Props> = props => {
<>
{result.map(item =>
item.query ? (
<div>
<StyledSyntaxContainer key={item.query}>
<CopyToClipboard
text={item.query}
shouldShowText={false}
@ -103,13 +111,13 @@ const ViewQueryModal: React.FC<Props> = props => {
</CopyButtonViewQuery>
}
/>
<SyntaxHighlighter
<StyledSyntaxHighlighter
language={item.language || undefined}
style={github}
>
{item.query}
</SyntaxHighlighter>
</div>
</StyledSyntaxHighlighter>
</StyledSyntaxContainer>
) : null,
)}
</>