diff --git a/.changeset/itchy-teachers-dance.md b/.changeset/itchy-teachers-dance.md new file mode 100644 index 0000000000..616c58a27a --- /dev/null +++ b/.changeset/itchy-teachers-dance.md @@ -0,0 +1,6 @@ +--- +"@twilio-paste/side-panel": minor +"@twilio-paste/core": minor +--- + +[Side Panel] Update mobile styles, add useSidePanelState hook, animation fixes diff --git a/.changeset/lazy-months-roll.md b/.changeset/lazy-months-roll.md new file mode 100644 index 0000000000..5ac37df623 --- /dev/null +++ b/.changeset/lazy-months-roll.md @@ -0,0 +1,5 @@ +--- +"@twilio-paste/codemods": minor +--- + +[Codemods] new export from Side Panel: useSidePanelState() diff --git a/packages/paste-codemods/tools/.cache/mappings.json b/packages/paste-codemods/tools/.cache/mappings.json index 02e39e8b8b..f720188cbe 100644 --- a/packages/paste-codemods/tools/.cache/mappings.json +++ b/packages/paste-codemods/tools/.cache/mappings.json @@ -263,6 +263,7 @@ "SidePanelHeader": "@twilio-paste/core/side-panel", "SidePanelHeaderActions": "@twilio-paste/core/side-panel", "SidePanelPushContentWrapper": "@twilio-paste/core/side-panel", + "useSidePanelState": "@twilio-paste/core/side-panel", "Sidebar": "@twilio-paste/core/sidebar", "SidebarBetaBadge": "@twilio-paste/core/sidebar", "SidebarBody": "@twilio-paste/core/sidebar", diff --git a/packages/paste-core/components/side-panel/package.json b/packages/paste-core/components/side-panel/package.json index 48acaad5e3..8e93de05fc 100644 --- a/packages/paste-core/components/side-panel/package.json +++ b/packages/paste-core/components/side-panel/package.json @@ -34,6 +34,7 @@ "@twilio-paste/customization": "^8.1.1", "@twilio-paste/design-tokens": "^10.3.0", "@twilio-paste/icons": "^12.4.0", + "@twilio-paste/modal-dialog-primitive": "^2.0.1", "@twilio-paste/spinner": "^14.1.2", "@twilio-paste/stack": "^8.1.0", "@twilio-paste/style-props": "^9.1.1", @@ -57,6 +58,7 @@ "@twilio-paste/customization": "^8.1.1", "@twilio-paste/design-tokens": "^10.7.0", "@twilio-paste/icons": "^12.7.0", + "@twilio-paste/modal-dialog-primitive": "^2.0.1", "@twilio-paste/spinner": "^14.1.2", "@twilio-paste/stack": "^8.1.0", "@twilio-paste/style-props": "^9.1.1", diff --git a/packages/paste-core/components/side-panel/src/SidePanel.tsx b/packages/paste-core/components/side-panel/src/SidePanel.tsx index 6d33b5aed9..93b8949f89 100644 --- a/packages/paste-core/components/side-panel/src/SidePanel.tsx +++ b/packages/paste-core/components/side-panel/src/SidePanel.tsx @@ -1,12 +1,41 @@ import { animated, useTransition } from "@twilio-paste/animation-library"; -import { Box, safelySpreadBoxProps } from "@twilio-paste/box"; +import { Box, getCustomElementStyles, safelySpreadBoxProps } from "@twilio-paste/box"; import type { BoxProps } from "@twilio-paste/box"; +import { ModalDialogPrimitiveContent, ModalDialogPrimitiveOverlay } from "@twilio-paste/modal-dialog-primitive"; +import { css, styled } from "@twilio-paste/styling-library"; +import { pasteBaseStyles, useTheme } from "@twilio-paste/theme"; import { useMergeRefs, useWindowSize } from "@twilio-paste/utils"; import * as React from "react"; import { SidePanelContext } from "./SidePanelContext"; import type { SidePanelProps } from "./types"; +const SidePanelMobileOverlay = animated( + // @ts-expect-error the styled div color prop from emotion is clashing with our color style prop in BoxProps + styled(ModalDialogPrimitiveOverlay)( + css({ + backgroundColor: "colorBackgroundOverlay", + position: "fixed", + top: 0, + right: 0, + bottom: 0, + left: 0, + width: "100%", + zIndex: "zIndex80", + }), + /* + * import Paste Theme Based Styles due to portal positioning. + * reach portal is a sibling to the main app, so you are now + * no longer a child of the theme provider. We need to re-set + * some of the base styles that we rely on inheriting from + * such as font-family and line-height so that compositions + * of paste components in the side panel are styled correctly. + */ + pasteBaseStyles, + getCustomElementStyles, + ), +); + const StyledSidePanelWrapper = React.forwardRef((props, ref) => ( ((props paddingRight={["space0", "space40", "space40"]} width={["100%", "size40", "size40"]} height={props.height} + boxSizing="content-box" /> )); @@ -31,87 +61,148 @@ const config = { friction: 20, }; -const transitionStyles = { - from: { opacity: 0, transform: "translateX(100%)" }, - enter: { opacity: 1, transform: "translateX(0%)" }, - leave: { opacity: 0, transform: "translateX(100%)" }, - config, -}; - -const mobileTransitionStyles = { - from: { opacity: 0, transform: "translateY(100%)" }, - enter: { opacity: 1, transform: "translateY(0%)" }, - leave: { opacity: 0, transform: "translateY(100%)" }, - config, -}; - -const SidePanel = React.forwardRef( - ({ element = "SIDE_PANEL", label, children, ...props }, ref) => { - const { sidePanelId, isOpen } = React.useContext(SidePanelContext); - - const { breakpointIndex } = useWindowSize(); - - const transitions = - breakpointIndex === 0 ? useTransition(isOpen, mobileTransitionStyles) : useTransition(isOpen, transitionStyles); - - const screenSize = window.innerHeight; +interface SidePanelContentsProps extends SidePanelProps { + sidePanelId: string; + styles: any; + isMobile: boolean; +} +const SidePanelContents = React.forwardRef( + ({ label, element, sidePanelId, styles, isMobile, children, ...props }, ref) => { + // Get the offset of the side panel from the top of the viewport const sidePanelRef = React.useRef(null); const mergedSidePanelRef = useMergeRefs(sidePanelRef, ref) as React.RefObject; - + const screenSize = window.innerHeight; const [offsetY, setOffsetY] = React.useState(0); - - // Get the offset of the side panel from the top of the viewport React.useEffect(() => { const boundingClientRect = sidePanelRef?.current?.getBoundingClientRect(); setOffsetY(boundingClientRect?.y || 0); }, []); + return ( + + + + {children} + + + + ); + }, +); +SidePanelContents.displayName = "SidePanelContents"; + +const SidePanel = React.forwardRef( + ({ element = "SIDE_PANEL", label, children, ...props }, ref) => { + const theme = useTheme(); + const { sidePanelId, isOpen } = React.useContext(SidePanelContext); + // Determine whether this is the initial render in order to block enter animations + const [isFirstRender, setIsFirstRender] = React.useState(true); + React.useEffect(() => { + if (isFirstRender) { + setIsFirstRender(false); + } + }, [isFirstRender]); + + // Define transition styles for both breakpoints + const transitionStyles = { + from: isFirstRender ? undefined : { opacity: 0, width: "0px" }, + enter: { opacity: 1, width: "400px" }, + leave: { opacity: 0, width: "0px" }, + config, + }; + const mobileTransitionStyles = { + from: isFirstRender ? undefined : { opacity: 0, transform: "translateY(100%)" }, + enter: { opacity: 1, transform: "translateY(0%)" }, + leave: { opacity: 0, transform: "translateY(100%)" }, + config, + }; + + // Set mobile or desktop transitions based on breakpointIndex + const { breakpointIndex } = useWindowSize(); + const desktopTransitions = useTransition(isOpen, transitionStyles); + const mobileTransitions = useTransition(isOpen, mobileTransitionStyles); + const transitions = React.useMemo(() => { + if (breakpointIndex === 0) return mobileTransitions; + return desktopTransitions; + }, [breakpointIndex, desktopTransitions, mobileTransitions]); + return ( <> {transitions( (styles, item) => - item && ( - - - - {children} - - - - ), + {children} + + + ) : ( + + {children} + + )), )} ); diff --git a/packages/paste-core/components/side-panel/src/SidePanelFooter.tsx b/packages/paste-core/components/side-panel/src/SidePanelFooter.tsx index 248ecee702..ebb51ded1a 100644 --- a/packages/paste-core/components/side-panel/src/SidePanelFooter.tsx +++ b/packages/paste-core/components/side-panel/src/SidePanelFooter.tsx @@ -11,7 +11,7 @@ const SidePanelFooter = React.forwardRef( paddingX={variant === "chat" ? "space50" : "space70"} paddingBottom="space50" paddingTop={variant === "chat" ? "space0" : "space50"} - boxShadow={variant === "chat" ? "none" : "shadow"} + boxShadow={variant === "chat" ? "none" : "shadowElevationTop05"} marginBottom="spaceNegative70" zIndex="zIndex20" display="flex" diff --git a/packages/paste-core/components/side-panel/src/SidePanelHeader.tsx b/packages/paste-core/components/side-panel/src/SidePanelHeader.tsx index d92a6b2528..6c325cfabc 100644 --- a/packages/paste-core/components/side-panel/src/SidePanelHeader.tsx +++ b/packages/paste-core/components/side-panel/src/SidePanelHeader.tsx @@ -4,15 +4,7 @@ import { CloseIcon } from "@twilio-paste/icons/esm/CloseIcon"; import * as React from "react"; import { SidePanelContext } from "./SidePanelContext"; -import type { SidePanelHeaderProps } from "./types"; - -type SidePanelCloseButtonProps = { - setIsOpen: React.Dispatch>; - i18nCloseSidePanelTitle: string; - sidePanelId: string; - isOpen: boolean; - element: string; -}; +import type { SidePanelCloseButtonProps, SidePanelHeaderProps } from "./types"; const SidePanelCloseButton: React.FC> = ({ setIsOpen, diff --git a/packages/paste-core/components/side-panel/src/SidePanelPushContentWrapper.tsx b/packages/paste-core/components/side-panel/src/SidePanelPushContentWrapper.tsx index a1d5da4220..51408d8f00 100644 --- a/packages/paste-core/components/side-panel/src/SidePanelPushContentWrapper.tsx +++ b/packages/paste-core/components/side-panel/src/SidePanelPushContentWrapper.tsx @@ -34,7 +34,7 @@ export const SidePanelPushContentWrapper = React.forwardRef { + const [isOpen, setIsOpen] = React.useState(open); + + return { + isOpen, + setIsOpen, + }; +}; diff --git a/packages/paste-core/components/side-panel/src/index.tsx b/packages/paste-core/components/side-panel/src/index.tsx index dd054b94f5..5399001a1a 100644 --- a/packages/paste-core/components/side-panel/src/index.tsx +++ b/packages/paste-core/components/side-panel/src/index.tsx @@ -17,5 +17,7 @@ export type { SidePanelContainerProps, SidePanelBodyProps, SidePanelFooterProps, + SidePanelStateReturn, } from "./types"; export { SidePanelContext } from "./SidePanelContext"; +export { useSidePanelState } from "./hooks"; diff --git a/packages/paste-core/components/side-panel/src/types.ts b/packages/paste-core/components/side-panel/src/types.ts index a2387f9baf..054160258a 100644 --- a/packages/paste-core/components/side-panel/src/types.ts +++ b/packages/paste-core/components/side-panel/src/types.ts @@ -175,3 +175,33 @@ export interface SidePanelContextProps { i18nCloseSidePanelTitle: string; i18nOpenSidePanelTitle: string; } + +export interface SidePanelStateReturn { + /** + * State for the Side Panel. Determines whether the Side Panel is open or closed. + * + * @type {boolean} + * @default false + * @memberof SidePanelStateReturn + */ + isOpen: boolean; + /** + * Sets the state of the Side Panel between open and closed. + * + * @type {React.Dispatch>} + * @memberof SidePanelStateReturn + */ + setIsOpen: React.Dispatch>; +} + +export interface UseSidePanelStateProps { + open?: boolean; +} + +export type SidePanelCloseButtonProps = { + setIsOpen: React.Dispatch>; + i18nCloseSidePanelTitle: string; + sidePanelId: string; + isOpen: boolean; + element: string; +}; diff --git a/packages/paste-core/components/side-panel/stories/index.stories.tsx b/packages/paste-core/components/side-panel/stories/index.stories.tsx index f40485c961..7488d359d6 100644 --- a/packages/paste-core/components/side-panel/stories/index.stories.tsx +++ b/packages/paste-core/components/side-panel/stories/index.stories.tsx @@ -28,6 +28,7 @@ import { SidePanelHeader, SidePanelHeaderActions, SidePanelPushContentWrapper, + useSidePanelState, } from "../src"; import { MessagingInsightsContent } from "./components/MessagingInsightsContent"; import { SidePanelWithAIContent } from "./components/SidePanelWithAIContent"; @@ -75,6 +76,37 @@ Default.parameters = { }, }; +export const NoContent: StoryFn = () => { + const sidePanel = useSidePanelState({ open: true }); + return ( + + + open sesame + + + headercontent + + + ); +}; +NoContent.parameters = { + padding: false, + a11y: { + config: { + rules: [ + { + /* + * Using position="relative" on SidePanel causes it to overflow other themes in stacked and side-by-side views, and therefore fail color contrast checks based on SidePanelBody's content. + * The DefaultVRT test below serves to test color contrast on the Side Panel component without this issue causing false failures. + */ + id: "color-contrast", + selector: "*:not(*)", + }, + ], + }, + }, +}; + export const Basic: StoryFn = () => { const [isOpen, setIsOpen] = React.useState(true); const sidePanelId = useUID(); @@ -115,6 +147,47 @@ Basic.parameters = { }, }; +export const AIMobile: StoryFn = () => { + const [isOpen, setIsOpen] = React.useState(true); + const sidePanelId = useUID(); + const topbarSkipLinkID = useUID(); + const mainContentSkipLinkID = useUID(); + return ( + <> + + + + + + + + Toggle Side Panel + + + + + + ); +}; +AIMobile.parameters = { + viewport: { defaultViewport: "iphonex" }, + padding: false, + a11y: { + config: { + rules: [ + { + /* + * Using position="relative" on SidePanel causes it to overflow other themes in stacked and side-by-side views, and therefore fail color contrast checks based on SidePanelBody's content. + * The DefaultVRT test below serves to test color contrast on the Side Panel component without this issue causing false failures. + */ + id: "color-contrast", + selector: "*:not(*)", + }, + ], + }, + }, +}; + export const AI: StoryFn = () => { const [isOpen, setIsOpen] = React.useState(true); const sidePanelId = useUID(); @@ -150,6 +223,44 @@ AI.parameters = { }, }; +export const FilterMobile: StoryFn = () => { + const [isOpen, setIsOpen] = React.useState(true); + const sidePanelId = useUID(); + return ( + + + + + + More filters + + 2 + + + + + + ); +}; +FilterMobile.parameters = { + viewport: { defaultViewport: "iphonex" }, + padding: false, + a11y: { + config: { + rules: [ + { + /* + * Using position="relative" on SidePanel causes it to overflow other themes in stacked and side-by-side views, and therefore fail color contrast checks based on SidePanelBody's content. + * The DefaultVRT test below serves to test color contrast on the Side Panel component without this issue causing false failures. + */ + id: "color-contrast", + selector: "*:not(*)", + }, + ], + }, + }, +}; + export const Filter: StoryFn = () => { const [isOpen, setIsOpen] = React.useState(true); const sidePanelId = useUID(); @@ -374,6 +485,49 @@ Composed.parameters = { }, }; +export const ComposedMobile: StoryFn = () => { + const [isOpen, setIsOpen] = React.useState(true); + + const topbarSkipLinkID = useUID(); + const mainContentSkipLinkID = useUID(); + + return ( + + {/* Sidebar can be placed anywhere - position fixed */} + + + + + + + + {/* Side Panel can be placed anywhere - position fixed */} + + + + + ); +}; + +ComposedMobile.parameters = { + viewport: { defaultViewport: "iphonex" }, + padding: false, + a11y: { + config: { + rules: [ + { + /* + * Using position="relative" on SidePanel causes it to overflow other themes in stacked and side-by-side views, and therefore fail color contrast checks based on SidePanelBody's content. + * The DefaultVRT test below serves to test color contrast on the Side Panel component without this issue causing false failures. + */ + id: "color-contrast", + selector: "*:not(*)", + }, + ], + }, + }, +}; + export const Customized: StoryFn = () => { const [isOpen, setIsOpen] = React.useState(true); const label = useUID(); diff --git a/packages/paste-website/src/components/shortcodes/live-preview/index.tsx b/packages/paste-website/src/components/shortcodes/live-preview/index.tsx index db3dfa6f10..9b6f284ab7 100644 --- a/packages/paste-website/src/components/shortcodes/live-preview/index.tsx +++ b/packages/paste-website/src/components/shortcodes/live-preview/index.tsx @@ -22,6 +22,7 @@ interface LivePreviewProps { disabled?: boolean; noInline?: boolean; showOverflow?: boolean; + height?: string; } const LivePreview: React.FC> = ({ @@ -31,6 +32,7 @@ const LivePreview: React.FC> = ({ noInline = false, showOverflow = false, scope, + height = "unset", }) => { const [viewCode, setViewCode] = React.useState(false); const id = useUID(); @@ -72,6 +74,7 @@ const LivePreview: React.FC> = ({ borderTopRightRadius="borderRadius20" position="relative" overflow={overflow} + height={height} > diff --git a/packages/paste-website/src/pages/components/paragraph/api.mdx b/packages/paste-website/src/pages/components/paragraph/api.mdx index 6e54b7df80..24fdfbc544 100644 --- a/packages/paste-website/src/pages/components/paragraph/api.mdx +++ b/packages/paste-website/src/pages/components/paragraph/api.mdx @@ -31,7 +31,7 @@ export const getStaticProps = async () => { pageHeaderData: { categoryRoute: SidebarCategoryRoutes.COMPONENTS, githubUrl: '/?path=/story/components-alert--neutral', - storybookUrl: 'https://github.com/twilio-labs/paste/tree/main/packages/paste-core/components/alert', + storybookUrl: '/?path=/story/components-paragraph--default', }, }, }; diff --git a/packages/paste-website/src/pages/components/side-panel/index.mdx b/packages/paste-website/src/pages/components/side-panel/index.mdx index c7e3c5c5c3..0934464950 100644 --- a/packages/paste-website/src/pages/components/side-panel/index.mdx +++ b/packages/paste-website/src/pages/components/side-panel/index.mdx @@ -8,6 +8,9 @@ export const meta = { import {Anchor} from '@twilio-paste/anchor'; import {Callout, CalloutHeading, CalloutText} from '@twilio-paste/callout'; +import {SidePanel, SidePanelContainer, SidePanelButton, SidePanelPushContentWrapper, SidePanelHeader, SidePanelBody, useSidePanelState} from '@twilio-paste/side-panel'; +import {Heading} from '@twilio-paste/heading'; +import {Separator} from '@twilio-paste/separator'; import {SidebarCategoryRoutes} from '../../../constants'; import { @@ -54,19 +57,21 @@ export const SidePanelExample = (): React.ReactNode => { - Heading + + + Assistant + - - Side Panel content goes here. - + Footer content goes here. @@ -86,6 +91,8 @@ export const SidePanelExample = (): React.ReactNode => { Side Panel is a container that pushes the main page content when open. It's important for page content to be responsive when using a Side Panel so that the opening and closing of the panel doesn't cause the page to jump or shift. At mobile breakpoints, the Side Panel overlays the page content and takes up the full width of the viewport. +Side Panel is primarily used within [AI experiences](/experiences/artificial-intelligence) and on pages using the [filter pattern](/patterns/filter) when there are too many filter options to display on the page. + Only use one Side Panel on a page @@ -127,19 +134,21 @@ export const SidePanelExample = (): React.ReactNode => { - Heading + + + Assistant + - - Side Panel content goes here. - + Footer content goes here. @@ -153,6 +162,49 @@ export const SidePanelExample = (): React.ReactNode => { }`} /> +### Side Panel with Footer + +Use the `default` variant of SidePanelFooter when you need to add actions to the bottom of the Side Panel. Use the `chat` variant of Side Panel Footer for AI use cases. + + { + const [isOpen, setIsOpen] = React.useState(true); + const sidePanelId = useUID(); + return ( + + + + + More filters + + + + + Side Panel content goes here. + + + Footer content goes here. + + + + + + More filters + + 2 + + + + + + ) +}`} +/> + ### Internationalization To internationalize Side Panel, simply pass different text as children to the Side Panel components. The only exceptions are the close button in the SidePanelHeader and the SidePanelButton/SidePanelBadgeButton. To change the buttons' accessible label text, use the `i18nCloseSidePanelTitle` and `i18nOpenSidePanel` props on the `SidePanelContainer`. @@ -167,9 +219,9 @@ export const SidePanelExample = (): React.ReactNode => { const sidePanelId = useUID(); return ( - + - Título + Título Side Panel content goes here. @@ -177,7 +229,7 @@ export const SidePanelExample = (): React.ReactNode => { - Probar Panel Lateral + Probar Panel Lateral @@ -185,6 +237,51 @@ export const SidePanelExample = (): React.ReactNode => { }`} /> +### Using the state hook + +Side Panel comes with the option of using a hook to manage the open and close state of the panel. The `useSidePanelState` hook returns an object to spread onto SidePanelContainer. To change the default state of the Side Panel from closed to open, pass `open: true` to the hook. + + + {`const SidePanelExample = () => { + const sidePanel = useSidePanelState({}); + return ( + + + + Toggle Side Panel + + + + + + Assistant + + + + + Side Panel content goes here. + + + + ) + } + render()`} + + ## Composition notes The Side Panel comes with some smaller components that can be used to compose a Side Panel to your application's needs. All of the following components should be used inside of a `SidePanelContainer`, with `SidePanel` and `SidePanelPushContentWrapper` being its direct children. The Side Panel Container controls the positioning of the Side Panel with relation to the page content. diff --git a/yarn.lock b/yarn.lock index e6e52febbb..bbb6292c91 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14852,6 +14852,7 @@ __metadata: "@twilio-paste/customization": ^8.1.1 "@twilio-paste/design-tokens": ^10.7.0 "@twilio-paste/icons": ^12.7.0 + "@twilio-paste/modal-dialog-primitive": ^2.0.1 "@twilio-paste/spinner": ^14.1.2 "@twilio-paste/stack": ^8.1.0 "@twilio-paste/style-props": ^9.1.1 @@ -14876,6 +14877,7 @@ __metadata: "@twilio-paste/customization": ^8.1.1 "@twilio-paste/design-tokens": ^10.3.0 "@twilio-paste/icons": ^12.4.0 + "@twilio-paste/modal-dialog-primitive": ^2.0.1 "@twilio-paste/spinner": ^14.1.2 "@twilio-paste/stack": ^8.1.0 "@twilio-paste/style-props": ^9.1.1