diff --git a/packages/ui-library/.storybook/style.css b/packages/ui-library/.storybook/style.css index c07e0cb543a..9e828172b90 100644 --- a/packages/ui-library/.storybook/style.css +++ b/packages/ui-library/.storybook/style.css @@ -30,6 +30,7 @@ @import "../src/components/modal/style.css"; @import "../src/components/notifications/style.css"; @import "../src/components/pagination/style.css"; +@import "../src/components/popover/style.css"; @import "../src/components/radio-group/style.css"; @import "../src/components/root/style.css"; @import "../src/components/select-field/style.css"; diff --git a/packages/ui-library/src/components/popover/docs/component.md b/packages/ui-library/src/components/popover/docs/component.md new file mode 100644 index 00000000000..86c438f763d --- /dev/null +++ b/packages/ui-library/src/components/popover/docs/component.md @@ -0,0 +1,4 @@ +A popover element is a type of modal interface used to display interactive content or additional information without navigating away from the current page. +Unlike a tooltip, which typically appears on hover and provides brief, non-interactive information, a popover is usually triggered by clicking an element and can contain +richer elements such as buttons, links, forms, or detailed explanations. The popover remains visible until the user explicitly dismisses it, offering a more +persistent and interactive experience than a tooltip. diff --git a/packages/ui-library/src/components/popover/docs/index.js b/packages/ui-library/src/components/popover/docs/index.js new file mode 100644 index 00000000000..a01392a1542 --- /dev/null +++ b/packages/ui-library/src/components/popover/docs/index.js @@ -0,0 +1 @@ +export { default as component } from "./component.md"; diff --git a/packages/ui-library/src/components/popover/index.js b/packages/ui-library/src/components/popover/index.js new file mode 100644 index 00000000000..f0a7b7c7783 --- /dev/null +++ b/packages/ui-library/src/components/popover/index.js @@ -0,0 +1,230 @@ +import classNames from "classnames"; +import PropTypes from "prop-types"; +import { XIcon } from "@heroicons/react/outline"; +import React, { createContext, forwardRef, useCallback, useContext, useEffect, useRef, Fragment } from "react"; +import { Transition } from "@headlessui/react"; +import { noop } from "lodash"; + +const PopoverContext = createContext( { handleDismiss: noop } ); + +const positionClassNameMap = { + "no-arrow": "yst-popover--no-arrow", + top: "yst-popover--top", + "top-left": "yst-popover--top-left", + "top-right": "yst-popover--top-right", + right: "yst-popover--right", + bottom: "yst-popover--bottom", + left: "yst-popover--left", + "bottom-left": "yst-popover--bottom-left", + "bottom-right": "yst-popover--bottom-right", +}; + +/** + * @returns {Object} The popover context. + */ +export const usePopoverContext = () => useContext( PopoverContext ); + +/** + * @param {string} dismissScreenReaderLabel The screen reader label for the dismiss button. + * @param {string} [className] The additional class name. + * @returns {JSX.Element} The close button. + */ +const CloseButton = ( { + dismissScreenReaderLabel, +} ) => { + const { handleDismiss } = usePopoverContext(); + const closeButtonRef = useRef( null ); + + return ( +
+ +
+ ); +}; + +CloseButton.propTypes = { + dismissScreenReaderLabel: PropTypes.string.isRequired, +}; + +/** + * @param {string} title The popover title. + * @param {string} id The id of the title. + * @param {string} [className] The additional class name. + * @returns {JSX.Element} The title. + */ +const Title = ( { + title, + id, + className, +} ) => { + return

+ { title } +

; +}; + +Title.propTypes = { + title: PropTypes.string.isRequired, + id: PropTypes.string, + className: PropTypes.string, +}; + +/** + * @param {string|string[]} content The popover content. + * @param {string } id The id of the content for accessibility. + * @param {string} [className] The additional class name. + * @returns {JSX.Element} The content. + */ +const Content = ( { + content, + id, + className, +} ) => { + return ( +

+ { content } +

+ ); +}; + +Content.propTypes = { + content: PropTypes.oneOfType( [ PropTypes.node, PropTypes.arrayOf( PropTypes.node ) ] ), + id: PropTypes.string, + className: PropTypes.string, +}; + +/** + * @param {string} [className] The additional class name. + * @param {boolean} isVisible Whether the backdrop is visible. + * @returns {JSX.Element} The backdrop. + */ +const Backdrop = ( { + className, isVisible, +} ) => { + useEffect( () => { + if ( isVisible ) { + document.body.classList.add( "backdrop-active" ); + } else { + document.body.classList.remove( "backdrop-active" ); + } + }, [ isVisible ] ); + return ( + +
+ + ); +}; + +Backdrop.propTypes = { + className: PropTypes.string, + isVisible: PropTypes.bool.isRequired, +}; + +/** + * @param {JSX.node} children Children of the popover. + * @param {string} id The popover id. + * @param {string} role The role of the popover. + * @param {string|JSX.Element} [as] Base component. + * @param {string} [className] Additional CSS classes. + * @param {string} [position] The position of the popover. + * @param {boolean} isVisible Whether the popover is visible. + * @param {Function} setIsVisible Function to set the visibility of the element. + * @param { JSX.Element } backdrop The backdrop of the popover. + * @returns {JSX.Element} The popover component. + */ + +const Popover = forwardRef( ( { + children, + id, + role, + as: Component, + className, + isVisible, + setIsVisible, + position, + backdrop, + ...props +}, ref ) => { + const handleDismiss = useCallback( () => { + setIsVisible( false ); + }, [ setIsVisible ] ); + + return ( + + { backdrop && } + + + { children } + + + + ); +} ); + +Popover.displayName = "Popover"; +Popover.propTypes = { + as: PropTypes.elementType, + children: PropTypes.node.isRequired, + id: PropTypes.string.isRequired, + role: PropTypes.string, + className: PropTypes.string, + isVisible: PropTypes.bool, + setIsVisible: PropTypes.func, + position: PropTypes.oneOf( Object.keys( positionClassNameMap ) ), + backdrop: PropTypes.bool, +}; + +Popover.defaultProps = { + as: "div", + role: "dialog", + isVisible: false, + setIsVisible: false, + position: "no-arrow", + backdrop: false, + className: "", +}; + +Popover.Title = Title; +Popover.CloseButton = CloseButton; +Popover.Content = Content; +Popover.Backdrop = Backdrop; + +export default Popover; + diff --git a/packages/ui-library/src/components/popover/stories.js b/packages/ui-library/src/components/popover/stories.js new file mode 100644 index 00000000000..00bdd2d9a0f --- /dev/null +++ b/packages/ui-library/src/components/popover/stories.js @@ -0,0 +1,160 @@ +import React, { useState } from "react"; +import Popover, { usePopoverContext } from "./index"; +import { component } from "./docs"; +import { InteractiveDocsPage } from "../../../.storybook/interactive-docs-page"; +import Button from "../../elements/button"; +import { noop } from "lodash"; +import { ValidationIcon } from "../../elements/validation"; + +const DismissButton = () => { + const { handleDismiss } = usePopoverContext(); + return ; +}; +export const Factory = { + component: Popover, + render: ( args ) => { + return ( + <> +
Element
+ + + + ); + }, + parameters: { + controls: { disable: true }, + }, + args: { + children: ( + + ), + }, +}; + +export const WithMoreContent = { + render: ( args ) => { + const [ isVisible, setIsVisible ] = useState( true ); + + return ( + <> + { isVisible && } + + ); + }, + args: { + children: ( + <> +
+
+ + +
+
+ +
+
+ + ), + }, + parameters: { + controls: { disable: true }, + }, +}; + +export const ButtonWithAPopover = { + render: ( args ) => { + const [ isVisible, setIsVisible ] = useState( false ); + + const handleClick = () => setIsVisible( ! isVisible ); + + return ( +
+ + +
+ ); + }, + parameters: { + controls: { disable: false }, + }, + args: { + backdrop: true, + children: ( + <> +
+
+ + +
+
+
+ +
+ +
+
+ +
+
+
+ +
+ + ), + }, +}; + +export default { + title: "2) Components/Popover", + component: Popover, + argTypes: { + children: { control: "text" }, + }, + args: { + id: "yst-popover", + isVisible: true, + setIsVisible: noop, + children: "", + backdrop: false, + }, + parameters: { + docs: { + description: { component }, + page: () => ( + + ), + }, + }, + decorators: [ + ( Story ) => ( +
+
+ +
+
+ ), + ], +}; diff --git a/packages/ui-library/src/components/popover/style.css b/packages/ui-library/src/components/popover/style.css new file mode 100644 index 00000000000..1ef233091aa --- /dev/null +++ b/packages/ui-library/src/components/popover/style.css @@ -0,0 +1,235 @@ +body[backdrop-active] * {/* Disable interaction with background elements when backdrop is active */ + pointer-events: none; +} + +@layer components { + .yst-root { + + .yst-close-button-wrapper { + @apply + yst-flex-shrink-0 + yst-flex + yst-self-start; + + & button { + @apply + yst-bg-transparent + yst-rounded-md + yst-inline-flex + yst-text-slate-400 + hover:yst-text-slate-500 + focus:yst-outline-none + focus:yst-ring-2 + focus:yst-ring-primary-500; + } + } + + .yst-popover-title { + @apply + yst-text-sm + yst-font-medium + yst-text-slate-800 + rtl:yst-text-right; + } + + .yst-popover-backdrop { + @apply + yst-fixed + yst-inset-0 + yst-bg-slate-500 + yst-bg-opacity-75 + yst-z-20 + yst-pointer-events-auto; + } + + .yst-popover { + @apply + yst-absolute + yst-whitespace-normal + yst-p-4 + yst-rounded-lg + yst-w-max + yst-max-w-xs + sm:yst-max-w-sm + yst-z-30 + yst-bg-white + yst-shadow-2xl + yst-gap-3 + yst-border + before:yst-absolute; + } + + .yst-popover--no-arrow { + @apply + yst--translate-x-1/2 + yst-start-1/2 + } + + .yst-popover--right { + @apply + yst-translate-x-5 + rtl:yst--translate-x-5 + yst--translate-y-1/2 + yst-top-1/2 + yst-start-full + before:yst-content-[''] + before:yst-block + before:yst-end-full + before:yst-top-1/2 + before:yst-right-full + before:yst--translate-y-1/2 + rtl:before:yst-translate-x-1/2; + + &::before { + border-left: 14px solid transparent; + border-right: 14px solid #fff; + border-top: 14px solid transparent; + border-bottom: 14px solid transparent; + } + } + + .yst-popover--top { + @apply + yst--translate-y-full + yst-translate-x-1/2 + rtl:yst--translate-x-1/2 + yst-end-1/2 + yst--top-5 + before:yst-content-[''] + before:yst-start-1/2 + before:yst-top-full + before:yst--translate-x-1/2 + rtl:before:yst-translate-x-1/2 + before:yst-translate-y-0; + + &::before { + border-right: 14px solid transparent; + border-top: 14px solid #fff; + border-left: 14px solid transparent; + } + } + + .yst-popover--top-left { + @apply + yst--translate-y-full + yst--top-5 + yst-end-0 + yst-translate-x-0 + before:yst-content-[''] + before:yst-end-2 /* move arrow to the right */ + before:yst-top-full + before:yst--translate-x-1/2 + rtl:before:yst-translate-x-1/2 + before:yst-translate-y-0 + before:yst-border-transparent; + + &::before { + border-right: 14px solid transparent; + border-top: 14px solid #fff; + border-left: 14px solid transparent; + } + } + + .yst-popover--top-right { + @apply + yst--translate-y-full + yst--top-5 + yst--start-0 + before:yst-content-[''] + before:yst-start-8 /* move arrow to the left */ + before:yst-top-full + before:yst--translate-x-1/2 + rtl:before:yst-translate-x-1/2 + before:yst-translate-y-0 + before:yst-border-transparent; + + &::before { + border-right: 14px solid transparent; + border-top: 14px solid #fff; + border-left: 14px solid transparent; + } + } + + .yst-popover--left { + @apply + yst-top-1/2 + yst-end-full + yst--start-5 + yst--translate-x-full + yst--translate-y-1/2 + rtl:yst-start-full + rtl:yst--end-5 + rtl:yst-translate-x-full + before:yst-content-[''] + before:yst-start-full + before:yst-top-1/2 + before:yst-right-full + before:yst--translate-y-1/2 + rtl:before:yst-translate-x-1/2; + + &::before { + border-right: 14px solid transparent; + border-top: 14px solid transparent; + border-bottom: 14px solid transparent; + border-left: 14px solid #fff; + } + } + + .yst-popover--bottom { + @apply + yst--translate-x-1/2 + yst-start-1/2 + yst-top-14 + before:yst-content-[''] + before:yst-start-1/2 + before:yst-bottom-full + before:yst--translate-x-1/2 + rtl:before:yst-translate-x-1/2 + before:yst-translate-y-0; + + &::before { + border-right: 14px solid transparent; + border-bottom: 14px solid #fff; + border-left: 14px solid transparent; + } + } + + .yst-popover--bottom-left { + @apply + yst-end-0 + yst-top-14 + before:yst-content-[''] + before:yst-end-0 + before:yst-bottom-full + before:yst--translate-x-1/2 + rtl:before:yst-translate-x-1/2 + before:yst-translate-y-0 + before:yst-border-transparent; + + &::before { + border-right: 14px solid transparent; + border-bottom: 14px solid #fff; + border-left: 14px solid transparent; + } + } + + .yst-popover--bottom-right { + @apply + yst-start-0 + yst-top-14 + before:yst-content-[''] + before:yst-start-8 + before:yst-bottom-full + before:yst--translate-x-1/2 + rtl:before:yst-translate-x-1/2 + before:yst-translate-y-0 + before:yst-border-transparent; + + &::before { + border-right: 14px solid transparent; + border-bottom: 14px solid #fff; + border-left: 14px solid transparent; + } + } + } +} diff --git a/packages/ui-library/src/index.js b/packages/ui-library/src/index.js index 1c4c9c962a8..043452faa1e 100644 --- a/packages/ui-library/src/index.js +++ b/packages/ui-library/src/index.js @@ -32,6 +32,7 @@ export { default as FileImport } from "./components/file-import"; export { default as Modal } from "./components/modal"; export { default as Notifications, useNotificationsContext } from "./components/notifications"; export { default as Pagination } from "./components/pagination"; +export { default as Popover, usePopoverContext } from "./components/popover"; export { default as RadioGroup } from "./components/radio-group"; export { default as Root } from "./components/root"; export { default as SelectField } from "./components/select-field";