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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,20 +1,35 @@
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import * as React from "react"; import React, { useContext } from "react";
import * as ReactDOM from "react-dom"; import * as ReactDOM from "react-dom";
import { XIcon } from "@heroicons/react/solid"; 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 = () => ( type ModalContextShape = {
<motion.div close: () => void;
className="absolute inset-0 -z-10 bg-black" };
initial={{ opacity: 0 }} const ModalContext = React.createContext<ModalContextShape>({
animate={{ opacity: 0.3 }} 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<{ const ModalHeader: React.FC<{
close: () => void;
children: React.ReactNode; children: React.ReactNode;
}> = ({ children, close }) => { }> = ({ children }) => {
const { close } = useContext(ModalContext);
return ( return (
<header className="px-5 py-3 border-b border-gray-200 font-bold flex items-center justify-between"> <header className="px-5 py-3 border-b border-gray-200 font-bold flex items-center justify-between">
<div>{children}</div> <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> <div className="px-5 py-3 border-t border-gray-200">{children}</div>
); );
const ModalWindow: React.FC<{ children: React.ReactNode }> = ({ children }) => ( const ModalWindow: React.FC<{
<div children: React.ReactNode;
className="bg-white rounded shadow-toast overflow-auto flex flex-col mx-2 w-96" container?: HTMLElement;
style={{ maxHeight: "calc(100% - 20px)", maxWidth: "calc(100% - 20px)" }} }> = ({ children, container }) => {
> // This component works in two possible modes:
{children} // 1. container mode - the modal is rendered inside a container element
</div> // 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; Body: typeof ModalBody;
Footer: typeof ModalFooter; Footer: typeof ModalFooter;
Header: typeof ModalHeader; Header: typeof ModalHeader;
}; };
export const Modal: ModalType = ({ children }) => { export const Modal: ModalType = ({ children, container, close }) => {
const [el] = React.useState(() => document.createElement("div")); const [el] = React.useState(() => document.createElement("div"));
React.useEffect(() => { React.useEffect(() => {
@ -68,15 +153,19 @@ export const Modal: ModalType = ({ children }) => {
}, [el]); }, [el]);
const modal = ( const modal = (
<div className="squiggle"> <SquiggleContainer>
<div className="fixed inset-0 z-40 flex justify-center items-center"> <ModalContext.Provider value={{ close }}>
<Overlay /> <div className="squiggle">
<ModalWindow>{children}</ModalWindow> <div className="fixed inset-0 z-40 flex justify-center items-center">
</div> <Overlay />
</div> <ModalWindow container={container}>{children}</ModalWindow>
</div>
</div>
</ModalContext.Provider>
</SquiggleContainer>
); );
return ReactDOM.createPortal(modal, el); return ReactDOM.createPortal(modal, container || el);
}; };
Modal.Body = ModalBody; Modal.Body = ModalBody;

View File

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