Add Popover2 component

Summary:
The current Popover component isn't compatible with the new (in progress) component library because it relies on overflowing container elements, which isn't allowed in the generic Layout component.

So this is a new Popover element, which uses absolute positioning instead. It takes heavy inspiration from the Tooltip and TooltipProvider components which do a similar thing.

Still to do:
[x] Edge cases when popover would be near a window edge
[x] Style it to look nice
[x] Split the use case (RatingButton) changes into a separate diff
[x] When the location of the popover container moves (the rating button in this case, e.g. if you resize the window), it doesn't currently cause the effect function in the popover, so it doesn't get moved when it should
[x] Add a little pointer thingy like a speech bubble
[x] Make sure it's perfectly positioned
[ ] Rename it to Popover and delete the old one. Not done, since it's just a stopgap.

Reviewed By: mweststrate

Differential Revision: D22693105

fbshipit-source-id: bc141433914bc20da48f8ae96764a95f7cd74ce5
This commit is contained in:
John Knox
2020-07-24 07:11:49 -07:00
committed by Facebook GitHub Bot
parent 5a56ee65b8
commit d7a6356fb6
3 changed files with 233 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import React, {useEffect, ReactNode} from 'react';
import {useContext} from 'react';
import {PopoverContext} from './PopoverProvider';
/**
* Popover element to be used as a stopgap until we adopt a
* UI framework.
* I don't recommend using this, as it will likely be removed in future.
* Must be nested under a PopoverProvider at some level, usually it is at the top level app so you shouldn't need to add it.
*/
export default function Popover2(props: {
id: string;
targetRef: React.RefObject<HTMLElement | null>;
children: ReactNode;
}) {
const popoverManager = useContext(PopoverContext);
useEffect(() => {
if (props.targetRef.current) {
popoverManager.open(props.id, props.targetRef, props.children);
return () => {
popoverManager.close(props.id);
};
}
});
return null;
}

View File

@@ -0,0 +1,158 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {
createContext,
useMemo,
useState,
ReactNode,
useEffect,
useRef,
RefObject,
} from 'react';
import React from 'react';
import {styled, colors} from 'flipper';
import {useWindowSize} from '../../utils/useWindowSize';
type PopoverManager = {
open(
id: string,
targetRef: RefObject<HTMLElement | null>,
content: ReactNode,
): void;
close(id: string): void;
};
type Popover = {
id: string;
targetRef: RefObject<HTMLElement | null>;
content: ReactNode;
};
const Anchor = styled.img((props: {top: number; left: number}) => ({
zIndex: 9999999,
position: 'absolute',
top: props.top,
left: props.left,
}));
Anchor.displayName = 'Popover.Anchor';
const ANCHOR_WIDTH = 34;
const PopoverContainer = styled('div')(
(props: {left: number; top: number; hidden: boolean}) => ({
position: 'absolute',
top: props.top,
left: props.left,
zIndex: 9999998,
backgroundColor: colors.white,
borderRadius: 7,
border: '1px solid rgba(0,0,0,0.3)',
boxShadow: '0 2px 10px 0 rgba(0,0,0,0.3)',
display: props.hidden ? 'none' : 'visible',
}),
);
PopoverContainer.displayName = 'Popover.PopoverContainer';
const PopoverElement = (props: {
targetRef: RefObject<HTMLElement | null>;
children: ReactNode;
}) => {
const ref = useRef<HTMLDivElement | null>(null);
const [dimensions, setDimensions] = useState<{
width: number;
height: number;
} | null>(null);
useEffect(() => {
if (!ref.current) {
return;
}
if (
dimensions?.width !== ref.current.clientWidth ||
dimensions?.height !== ref.current.clientHeight
) {
setDimensions({
width: ref.current?.clientWidth,
height: ref.current?.clientHeight,
});
}
});
const windowSize = useWindowSize();
if (
windowSize.height == null ||
windowSize.width == null ||
props.targetRef.current?.getBoundingClientRect() == null
) {
return null;
}
// target is the point that the anchor points to.
// It is defined as the center of the bottom edge of the target element.
const targetXCoord =
props.targetRef.current?.getBoundingClientRect().left +
props.targetRef.current?.getBoundingClientRect().width / 2;
const targetYCoord = props.targetRef.current?.getBoundingClientRect().bottom;
return (
<>
<Anchor
top={targetYCoord}
left={targetXCoord - ANCHOR_WIDTH / 2}
src="./anchor.svg"
key="anchor"
/>
<PopoverContainer
ref={ref}
hidden={ref.current === null}
top={
Math.min(
targetYCoord,
windowSize.height - (dimensions?.height ?? 0),
) + 13
}
left={Math.min(
targetXCoord - (dimensions?.width ?? 0) / 2,
windowSize.width - (dimensions?.width ?? 0),
)}>
{props.children}
</PopoverContainer>
</>
);
};
export const PopoverContext = createContext<PopoverManager>(undefined as any);
export function PopoverProvider({children}: {children: React.ReactNode}) {
const [popovers, setPopovers] = useState<Popover[]>([]);
const popoverManager = useMemo(
() => ({
open: (
id: string,
targetRef: RefObject<HTMLElement | null>,
content: ReactNode,
) => {
setPopovers((s) => [...s, {id, targetRef: targetRef, content}]);
},
close: (id: string) => {
setPopovers((s) => s.filter((p) => p.id !== id));
},
}),
[],
);
return (
<>
{popovers.map((p, index) => (
<PopoverElement key={index} targetRef={p.targetRef}>
{p.content}
</PopoverElement>
))}
<PopoverContext.Provider value={popoverManager}>
{children}
</PopoverContext.Provider>
</>
);
}

View File

@@ -0,0 +1,38 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {useEffect, useState} from 'react';
const isClient = typeof window === 'object';
function getSize() {
return {
width: isClient ? window.innerWidth : undefined,
height: isClient ? window.innerHeight : undefined,
};
}
export function useWindowSize() {
const [windowSize, setWindowSize] = useState(getSize);
useEffect(() => {
if (!isClient) {
return;
}
function handleResize() {
setWindowSize(getSize());
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []); // Empty array ensures that effect is only run on mount and unmount
return windowSize;
}