smart modal positioning
This commit is contained in:
parent
a11030814c
commit
b76e1df819
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,17 +360,30 @@ 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>
|
||||
<PlaygroundContext.Provider value={{ getLeftPanelElement }}>
|
||||
<StyledTab.Group>
|
||||
<div className="pb-4">
|
||||
<div className="flex justify-between items-center">
|
||||
|
@ -379,6 +407,7 @@ export const SquigglePlayground: FC<PlaygroundProps> = ({
|
|||
{vars.showEditor ? withEditor : withoutEditor}
|
||||
</div>
|
||||
</StyledTab.Group>
|
||||
</PlaygroundContext.Provider>
|
||||
</SquiggleContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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" ? (
|
||||
|
|
|
@ -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 = () => (
|
||||
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.3 }}
|
||||
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 }) => (
|
||||
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.
|
||||
|
||||
// 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="bg-white rounded shadow-toast overflow-auto flex flex-col mx-2 w-96"
|
||||
style={{ maxHeight: "calc(100% - 20px)", maxWidth: "calc(100% - 20px)" }}
|
||||
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 }> & {
|
||||
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 = (
|
||||
<SquiggleContainer>
|
||||
<ModalContext.Provider value={{ close }}>
|
||||
<div className="squiggle">
|
||||
<div className="fixed inset-0 z-40 flex justify-center items-center">
|
||||
<Overlay />
|
||||
<ModalWindow>{children}</ModalWindow>
|
||||
<ModalWindow container={container}>{children}</ModalWindow>
|
||||
</div>
|
||||
</div>
|
||||
</ModalContext.Provider>
|
||||
</SquiggleContainer>
|
||||
);
|
||||
|
||||
return ReactDOM.createPortal(modal, el);
|
||||
return ReactDOM.createPortal(modal, container || el);
|
||||
};
|
||||
|
||||
Modal.Body = ModalBody;
|
||||
|
|
|
@ -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==
|
||||
|
|
Loading…
Reference in New Issue
Block a user