smart modal positioning

This commit is contained in:
Vyacheslav Matyukhin 2022-07-26 20:17:27 +04:00
parent a11030814c
commit b76e1df819
No known key found for this signature in database
GPG Key ID: 3D2A774C5489F96C
8 changed files with 226 additions and 61 deletions

View File

@ -51,6 +51,7 @@ export interface SquiggleChartProps {
maxX?: number;
/** Whether to show vega actions to the user, so they can copy the chart spec */
distributionChartActions?: boolean;
enableLocalSettings?: boolean;
}
const defaultOnChange = () => {};
@ -76,6 +77,7 @@ export const SquiggleChart: React.FC<SquiggleChartProps> = React.memo(
color,
title,
distributionChartActions,
enableLocalSettings = false,
}) => {
const result = useSquiggle({
code,
@ -111,6 +113,7 @@ export const SquiggleChart: React.FC<SquiggleChartProps> = React.memo(
distributionPlotSettings={distributionPlotSettings}
chartSettings={chartSettings}
environment={environment ?? defaultEnvironment}
enableLocalSettings={enableLocalSettings}
/>
);
}

View File

@ -13,6 +13,7 @@ const SquiggleContext = React.createContext<SquiggleContextShape>({
export const SquiggleContainer: React.FC<Props> = ({ children }) => {
const context = useContext(SquiggleContext);
if (context.containerized) {
return <>{children}</>;
} else {

View File

@ -1,4 +1,11 @@
import React, { FC, useState, useEffect, useMemo } from "react";
import React, {
FC,
useState,
useEffect,
useMemo,
useRef,
useCallback,
} from "react";
import { useForm, UseFormRegister, useWatch } from "react-hook-form";
import * as yup from "yup";
import { useMaybeControlledValue } from "../lib/hooks";
@ -229,6 +236,13 @@ const useRunnerState = (code: string) => {
};
};
type PlaygroundContextShape = {
getLeftPanelElement: () => HTMLDivElement | undefined;
};
export const PlaygroundContext = React.createContext<PlaygroundContextShape>({
getLeftPanelElement: () => undefined,
});
export const SquigglePlayground: FC<PlaygroundProps> = ({
defaultCode = "",
height = 500,
@ -301,6 +315,7 @@ export const SquigglePlayground: FC<PlaygroundProps> = ({
{...vars}
bindings={defaultBindings}
jsImports={imports}
enableLocalSettings={true}
/>
);
@ -345,40 +360,54 @@ export const SquigglePlayground: FC<PlaygroundProps> = ({
</StyledTab.Panels>
);
const leftPanelRef = useRef<HTMLDivElement | null>(null);
const withEditor = (
<div className="flex mt-2">
<div className="w-1/2">{tabs}</div>
<div
className="w-1/2 relative"
style={{ minHeight: height }}
ref={leftPanelRef}
>
{tabs}
</div>
<div className="w-1/2 p-2 pl-4">{squiggleChart}</div>
</div>
);
const withoutEditor = <div className="mt-3">{tabs}</div>;
const getLeftPanelElement = useCallback(() => {
return leftPanelRef.current ?? undefined;
}, []);
return (
<SquiggleContainer>
<StyledTab.Group>
<div className="pb-4">
<div className="flex justify-between items-center">
<StyledTab.List>
<StyledTab
name={vars.showEditor ? "Code" : "Display"}
icon={vars.showEditor ? CodeIcon : EyeIcon}
<PlaygroundContext.Provider value={{ getLeftPanelElement }}>
<StyledTab.Group>
<div className="pb-4">
<div className="flex justify-between items-center">
<StyledTab.List>
<StyledTab
name={vars.showEditor ? "Code" : "Display"}
icon={vars.showEditor ? CodeIcon : EyeIcon}
/>
<StyledTab name="Sampling Settings" icon={CogIcon} />
<StyledTab name="View Settings" icon={ChartSquareBarIcon} />
<StyledTab name="Input Variables" icon={CurrencyDollarIcon} />
</StyledTab.List>
<RunControls
autorunMode={autorunMode}
isStale={renderedCode !== code}
run={run}
isRunning={isRunning}
onAutorunModeChange={setAutorunMode}
/>
<StyledTab name="Sampling Settings" icon={CogIcon} />
<StyledTab name="View Settings" icon={ChartSquareBarIcon} />
<StyledTab name="Input Variables" icon={CurrencyDollarIcon} />
</StyledTab.List>
<RunControls
autorunMode={autorunMode}
isStale={renderedCode !== code}
run={run}
isRunning={isRunning}
onAutorunModeChange={setAutorunMode}
/>
</div>
{vars.showEditor ? withEditor : withoutEditor}
</div>
{vars.showEditor ? withEditor : withoutEditor}
</div>
</StyledTab.Group>
</StyledTab.Group>
</PlaygroundContext.Provider>
</SquiggleContainer>
);
};

View File

@ -1,5 +1,5 @@
import { CogIcon } from "@heroicons/react/solid";
import React, { useContext, useState } from "react";
import React, { useContext, useRef, useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import { Modal } from "../ui/Modal";
@ -10,6 +10,7 @@ import {
defaultColor,
defaultTickFormat,
} from "../../lib/distributionSpecBuilder";
import { PlaygroundContext } from "../SquigglePlayground";
type Props = {
path: Path;
@ -18,12 +19,15 @@ type Props = {
withFunctionSettings: boolean;
};
const ItemSettingsModal: React.FC<Props & { close: () => void }> = ({
const ItemSettingsModal: React.FC<
Props & { close: () => void; resetScroll: () => void }
> = ({
path,
onChange,
disableLogX,
withFunctionSettings,
close,
resetScroll,
}) => {
const { setSettings, getSettings, getMergedSettings } =
useContext(ViewerContext);
@ -51,7 +55,7 @@ const ItemSettingsModal: React.FC<Props & { close: () => void }> = ({
diagramCount: mergedSettings.chartSettings.count,
},
});
React.useEffect(() => {
useEffect(() => {
const subscription = watch((vars) => {
const settings = getSettings(path); // get the latest version
setSettings(path, {
@ -78,10 +82,26 @@ const ItemSettingsModal: React.FC<Props & { close: () => void }> = ({
return () => subscription.unsubscribe();
}, [getSettings, setSettings, onChange, path, watch]);
const { getLeftPanelElement } = useContext(PlaygroundContext);
return (
<Modal>
<Modal.Header close={close}>
Chart settings{path.length ? " for " + pathAsString(path) : ""}
<Modal container={getLeftPanelElement()} close={close}>
<Modal.Header>
Chart settings
{path.length ? (
<>
{" for "}
<span
title="Scroll to item"
className="cursor-pointer"
onClick={resetScroll}
>
{pathAsString(path)}
</span>{" "}
</>
) : (
""
)}
</Modal.Header>
<Modal.Body>
<ViewSettings
@ -97,11 +117,25 @@ const ItemSettingsModal: React.FC<Props & { close: () => void }> = ({
export const ItemSettingsMenu: React.FC<Props> = (props) => {
const [isOpen, setIsOpen] = useState(false);
const { setSettings, getSettings } = useContext(ViewerContext);
const { enableLocalSettings, setSettings, getSettings } =
useContext(ViewerContext);
const ref = useRef<HTMLDivElement | null>(null);
if (!enableLocalSettings) {
return null;
}
const settings = getSettings(props.path);
const resetScroll = () => {
if (!ref.current) return;
window.scroll({
top: ref.current.getBoundingClientRect().y + window.scrollY,
});
};
return (
<div className="flex gap-2">
<div className="flex gap-2" ref={ref}>
<CogIcon
className="h-5 w-5 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => setIsOpen(!isOpen)}
@ -122,7 +156,11 @@ export const ItemSettingsMenu: React.FC<Props> = (props) => {
</button>
) : null}
{isOpen ? (
<ItemSettingsModal {...props} close={() => setIsOpen(false)} />
<ItemSettingsModal
{...props}
close={() => setIsOpen(false)}
resetScroll={resetScroll}
/>
) : null}
</div>
);

View File

@ -9,6 +9,7 @@ type ViewerContextShape = {
getSettings(path: Path): LocalItemSettings;
getMergedSettings(path: Path): MergedItemSettings;
setSettings(path: Path, value: LocalItemSettings): void;
enableLocalSettings: boolean; // show local settings icon in the UI
};
export const ViewerContext = React.createContext<ViewerContextShape>({
@ -30,4 +31,5 @@ export const ViewerContext = React.createContext<ViewerContextShape>({
height: 150,
}),
setSettings() {},
enableLocalSettings: false,
});

View File

@ -23,6 +23,7 @@ type Props = {
chartSettings: FunctionChartSettings;
/** Environment for further function executions */
environment: environment;
enableLocalSettings?: boolean;
};
type Settings = {
@ -38,6 +39,7 @@ export const SquiggleViewer: React.FC<Props> = ({
distributionPlotSettings,
chartSettings,
environment,
enableLocalSettings = false,
}) => {
// can't store settings in the state because we don't want to rerender the entire tree on every change
const settingsRef = useRef<Settings>({});
@ -85,6 +87,7 @@ export const SquiggleViewer: React.FC<Props> = ({
getSettings,
setSettings,
getMergedSettings,
enableLocalSettings,
}}
>
{result.tag === "Ok" ? (

View File

@ -1,20 +1,35 @@
import { motion } from "framer-motion";
import * as React from "react";
import React, { useContext } from "react";
import * as ReactDOM from "react-dom";
import { XIcon } from "@heroicons/react/solid";
import { SquiggleContainer } from "../SquiggleContainer";
import clsx from "clsx";
import { useWindowScroll, useWindowSize } from "react-use";
import { rectToClientRect } from "@floating-ui/core";
const Overlay: React.FC = () => (
<motion.div
className="absolute inset-0 -z-10 bg-black"
initial={{ opacity: 0 }}
animate={{ opacity: 0.3 }}
/>
);
type ModalContextShape = {
close: () => void;
};
const ModalContext = React.createContext<ModalContextShape>({
close: () => undefined,
});
const Overlay: React.FC = () => {
const { close } = useContext(ModalContext);
return (
<motion.div
className="absolute inset-0 -z-10 bg-black"
initial={{ opacity: 0 }}
animate={{ opacity: 0.1 }}
onClick={close}
/>
);
};
const ModalHeader: React.FC<{
close: () => void;
children: React.ReactNode;
}> = ({ children, close }) => {
}> = ({ children }) => {
const { close } = useContext(ModalContext);
return (
<header className="px-5 py-3 border-b border-gray-200 font-bold flex items-center justify-between">
<div>{children}</div>
@ -41,22 +56,92 @@ const ModalFooter: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div className="px-5 py-3 border-t border-gray-200">{children}</div>
);
const ModalWindow: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div
className="bg-white rounded shadow-toast overflow-auto flex flex-col mx-2 w-96"
style={{ maxHeight: "calc(100% - 20px)", maxWidth: "calc(100% - 20px)" }}
>
{children}
</div>
);
const ModalWindow: React.FC<{
children: React.ReactNode;
container?: HTMLElement;
}> = ({ children, container }) => {
// This component works in two possible modes:
// 1. container mode - the modal is rendered inside a container element
// 2. centered mode - the modal is rendered in the middle of the screen
// The mode is determined by the presence of the `container` prop and by whether the available space is large enough to fit the modal.
type ModalType = React.FC<{ children: React.ReactNode }> & {
// Necessary for container mode - need to reposition the modal on scroll and resize events.
useWindowSize();
useWindowScroll();
let position:
| {
left: number;
top: number;
maxWidth: number;
maxHeight: number;
transform: string;
}
| undefined;
const { clientWidth: screenWidth, clientHeight: screenHeight } =
document.documentElement;
if (container) {
const rect = container?.getBoundingClientRect();
// If available space in `visibleRect` is smaller than these, fallback to positioning in the middle of the screen.
const minWidth = 384; // matches the w-96 below
const minHeight = 300;
const offset = 8;
const visibleRect = {
left: Math.max(rect.left, 0),
right: Math.min(rect.right, screenWidth),
top: Math.max(rect.top, 0),
bottom: Math.min(rect.bottom, screenHeight),
};
const maxWidth = visibleRect.right - visibleRect.left - 2 * offset;
const maxHeight = visibleRect.bottom - visibleRect.top - 2 * offset;
const center = {
left: visibleRect.left + (visibleRect.right - visibleRect.left) / 2,
top: visibleRect.top + (visibleRect.bottom - visibleRect.top) / 2,
};
position = {
left: center.left,
top: center.top,
transform: "translate(-50%, -50%)",
maxWidth,
maxHeight,
};
if (maxWidth < minWidth || maxHeight < minHeight) {
position = undefined; // modal is hard to fit in the container, fallback to positioning it in the middle of the screen
}
}
return (
<div
className={clsx(
"bg-white rounded-md shadow-toast flex flex-col overflow-auto border w-96",
position ? "fixed" : null
)}
style={
position ?? {
maxHeight: "calc(100% - 20px)",
maxWidth: "calc(100% - 20px)",
}
}
>
{children}
</div>
);
};
type ModalType = React.FC<{
children: React.ReactNode;
container?: HTMLElement; // if specified, modal will be positioned over the visible part of the container, if it's not too small
close: () => void;
}> & {
Body: typeof ModalBody;
Footer: typeof ModalFooter;
Header: typeof ModalHeader;
};
export const Modal: ModalType = ({ children }) => {
export const Modal: ModalType = ({ children, container, close }) => {
const [el] = React.useState(() => document.createElement("div"));
React.useEffect(() => {
@ -68,15 +153,19 @@ export const Modal: ModalType = ({ children }) => {
}, [el]);
const modal = (
<div className="squiggle">
<div className="fixed inset-0 z-40 flex justify-center items-center">
<Overlay />
<ModalWindow>{children}</ModalWindow>
</div>
</div>
<SquiggleContainer>
<ModalContext.Provider value={{ close }}>
<div className="squiggle">
<div className="fixed inset-0 z-40 flex justify-center items-center">
<Overlay />
<ModalWindow container={container}>{children}</ModalWindow>
</div>
</div>
</ModalContext.Provider>
</SquiggleContainer>
);
return ReactDOM.createPortal(modal, el);
return ReactDOM.createPortal(modal, container || el);
};
Modal.Body = ModalBody;

View File

@ -4825,7 +4825,7 @@
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@^18.0.9":
"@types/react@*", "@types/react@^18.0.1", "@types/react@^18.0.9":
version "18.0.15"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.15.tgz#d355644c26832dc27f3e6cbf0c4f4603fc4ab7fe"
integrity sha512-iz3BtLuIYH1uWdsv6wXYdhozhqj20oD4/Hk2DNXIn1kFsmp9x8d9QB6FnPhfkbhd2PgEONt9Q1x/ebkwjfFLow==
@ -15261,7 +15261,7 @@ react-vega@^7.6.0:
prop-types "^15.8.1"
vega-embed "^6.5.1"
react@^18.1.0:
react@^18.0.0, react@^18.1.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==