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:
committed by
Facebook GitHub Bot
parent
5a56ee65b8
commit
d7a6356fb6
37
desktop/app/src/ui/components/Popover2.tsx
Normal file
37
desktop/app/src/ui/components/Popover2.tsx
Normal 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;
|
||||||
|
}
|
||||||
158
desktop/app/src/ui/components/PopoverProvider.tsx
Normal file
158
desktop/app/src/ui/components/PopoverProvider.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
desktop/app/src/utils/useWindowSize.tsx
Normal file
38
desktop/app/src/utils/useWindowSize.tsx
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user