diff --git a/package-lock.json b/package-lock.json index e12395b4f3..8b40122ed9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6717,6 +6717,11 @@ } } }, + "@popperjs/core": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.4.4.tgz", + "integrity": "sha512-1oO6+dN5kdIA3sKPZhRGJTfGVP4SWV6KqlMOwry4J3HfyD68sl/3KmG7DeYUzvN+RbhXDnv/D8vNNB8168tAMg==" + }, "@reach/router": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/@reach/router/-/router-1.3.4.tgz", @@ -28840,8 +28845,7 @@ "react-fast-compare": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", - "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==", - "dev": true + "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==" }, "react-helmet-async": { "version": "1.0.6", @@ -28943,19 +28947,23 @@ "prop-types": "^15.6.2" } }, - "react-popper": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-1.3.7.tgz", - "integrity": "sha512-nmqYTx7QVjCm3WUZLeuOomna138R1luC4EqkW3hxJUrAe+3eNz3oFCLYdnPwILfn0mX1Ew2c3wctrjlUMYYUww==", - "dev": true, + "react-popper-2": { + "version": "npm:react-popper@2.2.3", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.2.3.tgz", + "integrity": "sha512-mOEiMNT1249js0jJvkrOjyHsGvqcJd3aGW/agkiMoZk3bZ1fXN1wQszIQSjHIai48fE67+zwF8Cs+C4fWqlfjw==", "requires": { - "@babel/runtime": "^7.1.2", - "create-react-context": "^0.3.0", - "deep-equal": "^1.1.1", - "popper.js": "^1.14.4", - "prop-types": "^15.6.1", - "typed-styles": "^0.0.7", + "react-fast-compare": "^3.0.1", "warning": "^4.0.2" + } + }, + "react-popper-tooltip": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/react-popper-tooltip/-/react-popper-tooltip-2.11.1.tgz", + "integrity": "sha512-04A2f24GhyyMicKvg/koIOQ5BzlrRbKiAgP6L+Pdj1MVX3yJ1NeZ8+EidndQsbejFT55oW1b++wg2Z8KlAyhfQ==", + "dev": true, + "requires": { + "@babel/runtime": "^7.9.2", + "react-popper": "^1.3.7" }, "dependencies": { "deep-equal": { @@ -28977,19 +28985,24 @@ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "dev": true + }, + "react-popper": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-nmqYTx7QVjCm3WUZLeuOomna138R1luC4EqkW3hxJUrAe+3eNz3oFCLYdnPwILfn0mX1Ew2c3wctrjlUMYYUww==", + "dev": true, + "requires": { + "@babel/runtime": "^7.1.2", + "create-react-context": "^0.3.0", + "deep-equal": "^1.1.1", + "popper.js": "^1.14.4", + "prop-types": "^15.6.1", + "typed-styles": "^0.0.7", + "warning": "^4.0.2" + } } } }, - "react-popper-tooltip": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/react-popper-tooltip/-/react-popper-tooltip-2.11.1.tgz", - "integrity": "sha512-04A2f24GhyyMicKvg/koIOQ5BzlrRbKiAgP6L+Pdj1MVX3yJ1NeZ8+EidndQsbejFT55oW1b++wg2Z8KlAyhfQ==", - "dev": true, - "requires": { - "@babel/runtime": "^7.9.2", - "react-popper": "^1.3.7" - } - }, "react-router": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.0.tgz", @@ -29104,6 +29117,17 @@ "refractor": "^2.4.1" } }, + "react-test-renderer": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.13.1.tgz", + "integrity": "sha512-Sn2VRyOK2YJJldOqoh8Tn/lWQ+ZiKhyZTPtaO0Q6yNj+QDbmRkVFap6pZPy3YQk8DScRDfyqm/KxKYP9gCMRiQ==", + "requires": { + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "react-is": "^16.8.6", + "scheduler": "^0.19.1" + } + }, "react-textarea-autosize": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.2.0.tgz", @@ -29132,6 +29156,15 @@ } } }, + "react-use-css-breakpoints": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/react-use-css-breakpoints/-/react-use-css-breakpoints-1.0.3.tgz", + "integrity": "sha512-l1rg/gknC8bDELa3lW3Ph0tTTbn1J31gUx+pskbS1OxSEh/7aEQeT37ZB5IPfmDediriROSPERx0XKq7UEfC0w==", + "requires": { + "react": "^16.10.2", + "react-test-renderer": "^16.10.2" + } + }, "reactcss": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz", @@ -34348,7 +34381,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", - "dev": true, "requires": { "loose-envify": "^1.0.0" } diff --git a/package.json b/package.json index 54570bee5f..0ac6f02af5 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@fortawesome/react-fontawesome": "^0.1.0", "@mozillareality/easing-functions": "^0.1.1", "@mozillareality/three-batch-manager": "github:mozillareality/three-batch-manager#master", + "@popperjs/core": "^2.4.4", "aframe": "github:mozillareality/aframe#hubs/master", "aframe-rounded": "^1.0.3", "aframe-slice9-component": "^1.0.0", @@ -101,8 +102,10 @@ "react-infinite-scroller": "^1.2.2", "react-intl": "^2.4.0", "react-linkify": "^0.2.2", + "react-popper-2": "npm:react-popper@^2.2.3", "react-router": "^5.1.2", "react-router-dom": "^5.1.2", + "react-use-css-breakpoints": "^1.0.3", "screenfull": "^4.0.1", "semver": "^7.3.2", "three": "github:mozillareality/three.js#hubs/master", diff --git a/src/react-components/popover/Popover.js b/src/react-components/popover/Popover.js new file mode 100644 index 0000000000..ddbc37ff81 --- /dev/null +++ b/src/react-components/popover/Popover.js @@ -0,0 +1,116 @@ +import React, { useState, useCallback, useEffect } from "react"; +// Note react-popper-2 is just an alias to react-popper@2.2.3 because storybook is depending on an old version. +// https://github.com/storybookjs/storybook/issues/10982 +import { usePopper } from "react-popper-2"; +import styles from "./Popover.scss"; +import { createPortal } from "react-dom"; +import PropTypes from "prop-types"; +import { useCssBreakpoints } from "react-use-css-breakpoints"; +import classNames from "classnames"; +import { ReactComponent as CloseIcon } from "../icons/Close.svg"; +import { ReactComponent as PopoverArrow } from "./PopoverArrow.svg"; + +export function Popover({ content: Content, children, title, placement, initiallyVisible }) { + const [visible, setVisible] = useState(initiallyVisible); + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + const [arrowElement, setArrowElement] = useState(null); + const { + styles: { popper: popperStyles, arrow: arrowStyles }, + attributes + } = usePopper(referenceElement, popperElement, { + placement, + modifiers: [{ name: "arrow", options: { element: arrowElement } }, { name: "offset", options: { offset: [0, 16] } }] + }); + const breakpoint = useCssBreakpoints(); + const fullscreen = breakpoint === "sm"; + const closePopover = useCallback(() => setVisible(false), [setVisible]); + const togglePopover = useCallback(() => setVisible(visible => !visible), [setVisible]); + + useEffect( + () => { + const onClick = e => { + if ( + (referenceElement && referenceElement.contains(e.target)) || + (popperElement && popperElement.contains(e.target)) + ) { + return; + } + + setVisible(false); + }; + + if (visible) { + window.addEventListener("click", onClick); + } + + return () => { + window.removeEventListener("click", onClick); + }; + }, + [visible, popperElement, referenceElement] + ); + + useEffect( + () => { + if (visible && fullscreen) { + document.body.classList.add(styles.fullscreenBody); + } else { + document.body.classList.remove(styles.fullscreenBody); + } + + return () => { + document.body.classList.remove(styles.fullscreenBody); + }; + }, + [fullscreen, visible] + ); + + return ( + <> + {children({ + togglePopover, + popoverVisible: visible, + triggerRef: setReferenceElement + })} + {visible && + createPortal( +
+
+ +
{title}
+
+
+ {typeof Content === "function" ? : Content} +
+ {!fullscreen && ( +
+ +
+ )} +
, + document.body + )} + + ); +} + +Popover.propTypes = { + initiallyVisible: PropTypes.bool, + placement: PropTypes.string, + title: PropTypes.string.isRequired, + children: PropTypes.func.isRequired, + content: PropTypes.oneOfType([PropTypes.func, PropTypes.node]) +}; + +Popover.defaultProps = { + initiallyVisible: false, + placement: "auto" +}; diff --git a/src/react-components/popover/Popover.scss b/src/react-components/popover/Popover.scss new file mode 100644 index 0000000000..0a2ceb1e89 --- /dev/null +++ b/src/react-components/popover/Popover.scss @@ -0,0 +1,91 @@ +@use "../styles/theme"; + +:local(.popover) { + display: flex; + flex-direction: column; + border-radius: 8px; + background-color: theme.$white; + border: 1px solid theme.$lightgrey; + min-width: 160px; + + &[data-popper-placement^=bottom] :local(.arrow) { + margin-top: -9px; + + svg { + transform: rotate(180deg); + } + } + + &[data-popper-placement^=top] :local(.arrow) { + bottom: -9px; + } + + &[data-popper-placement^=right] :local(.arrow) { + left: -16px; + + svg { + transform: rotate(90deg); + } + } + + &[data-popper-placement^=left] :local(.arrow) { + right: -16px; + + svg { + transform: rotate(270deg); + } + } +} + +:local(.header) { + display: flex; + justify-content: center; + align-items: center; + padding: 0 8px; + height: 48px; + position: relative; + + h5 { + display: flex + } + + button { + position: absolute; + left: 8px; + border: none; + background-color: transparent; + + &:hover * { + stroke: theme.$black-hover; + } + + &:active * { + stroke: theme.$black-pressed; + } + } +} + +:local(.arrow) { + position: absolute; +} + +:local(.fullscreen) { + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + border-radius: 0; + + :local(.header) { + border-bottom: 1px solid theme.$lightgrey; + } + + :local(.content) { + overflow-y: auto; + } +} + +:local(.fullscreen-body) { + overflow: hidden; +} \ No newline at end of file diff --git a/src/react-components/popover/Popover.stories.js b/src/react-components/popover/Popover.stories.js new file mode 100644 index 0000000000..4b4e265f96 --- /dev/null +++ b/src/react-components/popover/Popover.stories.js @@ -0,0 +1,62 @@ +import React from "react"; +import { Popover } from "./Popover"; +import { ToolbarButton } from "../toolbar/ToolbarButton"; +import { ReactComponent as InviteIcon } from "../icons/Invite.svg"; + +export default { + title: "Popover", + argTypes: { + placement: { + control: { + type: "select", + options: [ + "auto", + "auto-start", + "auto-end", + "top", + "top-start", + "top-end", + "bottom", + "bottom-start", + "bottom-end", + "right", + "right-start", + "right-end", + "left", + "left-start", + "left-end" + ] + } + } + } +}; + +const containerStyles = { + width: "100%", + position: "relative", + padding: "200px" +}; + +export const Base = args => ( +
+ Content
} initiallyVisible {...args}> + {({ togglePopover, popoverVisible, triggerRef }) => ( + } + selected={popoverVisible} + onClick={togglePopover} + label="Invite" + /> + )} + + +); + +Base.parameters = { + layout: "fullscreen" +}; + +Base.args = { + placement: "auto" +}; diff --git a/src/react-components/popover/PopoverArrow.svg b/src/react-components/popover/PopoverArrow.svg new file mode 100644 index 0000000000..3f3b215cdf --- /dev/null +++ b/src/react-components/popover/PopoverArrow.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/react-components/styles/global.scss b/src/react-components/styles/global.scss index d6fba175d7..99f9648400 100644 --- a/src/react-components/styles/global.scss +++ b/src/react-components/styles/global.scss @@ -172,3 +172,43 @@ body:not(:global(.keyboard-user)) { outline: none; } } + +/** + * Breakpoint definitions for use wuth react-use-css-breakpoints + * https://github.com/matthewhall/react-use-css-breakpoints + */ +body::before { + content: "sm"; + display: none; +} + +@media (min-width: theme.$breakpoint-md) { + body::before { + content: "md"; + } +} + +@media (min-width: theme.$breakpoint-lg) { + body::before { + content: "lg"; + } +} + +@media (min-width: theme.$breakpoint-xl) { + body::before { + content: "xl"; + } +} + +@media (min-width: theme.$breakpoint-xxl) { + body::before { + content: "xxl"; + } +} + +// TODO: Add the rest of the base typography styles + +h5 { + font-size: theme.$font-size-small; + font-weight: theme.$font-weight-bold; +} diff --git a/src/react-components/toolbar/ToolbarButton.js b/src/react-components/toolbar/ToolbarButton.js index 019a4ce713..d3e889ac7a 100644 --- a/src/react-components/toolbar/ToolbarButton.js +++ b/src/react-components/toolbar/ToolbarButton.js @@ -1,13 +1,14 @@ -import React from "react"; +import React, { forwardRef } from "react"; import PropTypes from "prop-types"; import classNames from "classnames"; import styles from "./ToolbarButton.scss"; export const presets = ["basic", "transparent", "accept", "cancel", "red", "orange", "green", "blue", "purple"]; -export function ToolbarButton({ preset, className, icon, label, selected, ...rest }) { +export const ToolbarButton = forwardRef(({ preset, className, icon, label, selected, ...rest }, ref) => { return ( ); -} +}); ToolbarButton.propTypes = { icon: PropTypes.node,