Skip to content

Commit 3e9bf5f

Browse files
authored
feat(side-panel): add useSidePanelState hook, animation zhoosh (#4230)
1 parent 5da344e commit 3e9bf5f

File tree

16 files changed

+489
-92
lines changed

16 files changed

+489
-92
lines changed

.changeset/itchy-teachers-dance.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@twilio-paste/side-panel": minor
3+
"@twilio-paste/core": minor
4+
---
5+
6+
[Side Panel] Update mobile styles, add useSidePanelState hook, animation fixes

.changeset/lazy-months-roll.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@twilio-paste/codemods": minor
3+
---
4+
5+
[Codemods] new export from Side Panel: useSidePanelState()

packages/paste-codemods/tools/.cache/mappings.json

+1
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@
263263
"SidePanelHeader": "@twilio-paste/core/side-panel",
264264
"SidePanelHeaderActions": "@twilio-paste/core/side-panel",
265265
"SidePanelPushContentWrapper": "@twilio-paste/core/side-panel",
266+
"useSidePanelState": "@twilio-paste/core/side-panel",
266267
"Sidebar": "@twilio-paste/core/sidebar",
267268
"SidebarBetaBadge": "@twilio-paste/core/sidebar",
268269
"SidebarBody": "@twilio-paste/core/sidebar",

packages/paste-core/components/side-panel/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"@twilio-paste/customization": "^8.1.1",
3535
"@twilio-paste/design-tokens": "^10.3.0",
3636
"@twilio-paste/icons": "^12.4.0",
37+
"@twilio-paste/modal-dialog-primitive": "^2.0.1",
3738
"@twilio-paste/spinner": "^14.1.2",
3839
"@twilio-paste/stack": "^8.1.0",
3940
"@twilio-paste/style-props": "^9.1.1",
@@ -57,6 +58,7 @@
5758
"@twilio-paste/customization": "^8.1.1",
5859
"@twilio-paste/design-tokens": "^10.7.0",
5960
"@twilio-paste/icons": "^12.7.0",
61+
"@twilio-paste/modal-dialog-primitive": "^2.0.1",
6062
"@twilio-paste/spinner": "^14.1.2",
6163
"@twilio-paste/stack": "^8.1.0",
6264
"@twilio-paste/style-props": "^9.1.1",

packages/paste-core/components/side-panel/src/SidePanel.tsx

+158-67
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,41 @@
11
import { animated, useTransition } from "@twilio-paste/animation-library";
2-
import { Box, safelySpreadBoxProps } from "@twilio-paste/box";
2+
import { Box, getCustomElementStyles, safelySpreadBoxProps } from "@twilio-paste/box";
33
import type { BoxProps } from "@twilio-paste/box";
4+
import { ModalDialogPrimitiveContent, ModalDialogPrimitiveOverlay } from "@twilio-paste/modal-dialog-primitive";
5+
import { css, styled } from "@twilio-paste/styling-library";
6+
import { pasteBaseStyles, useTheme } from "@twilio-paste/theme";
47
import { useMergeRefs, useWindowSize } from "@twilio-paste/utils";
58
import * as React from "react";
69

710
import { SidePanelContext } from "./SidePanelContext";
811
import type { SidePanelProps } from "./types";
912

13+
const SidePanelMobileOverlay = animated(
14+
// @ts-expect-error the styled div color prop from emotion is clashing with our color style prop in BoxProps
15+
styled(ModalDialogPrimitiveOverlay)(
16+
css({
17+
backgroundColor: "colorBackgroundOverlay",
18+
position: "fixed",
19+
top: 0,
20+
right: 0,
21+
bottom: 0,
22+
left: 0,
23+
width: "100%",
24+
zIndex: "zIndex80",
25+
}),
26+
/*
27+
* import Paste Theme Based Styles due to portal positioning.
28+
* reach portal is a sibling to the main app, so you are now
29+
* no longer a child of the theme provider. We need to re-set
30+
* some of the base styles that we rely on inheriting from
31+
* such as font-family and line-height so that compositions
32+
* of paste components in the side panel are styled correctly.
33+
*/
34+
pasteBaseStyles,
35+
getCustomElementStyles,
36+
),
37+
);
38+
1039
const StyledSidePanelWrapper = React.forwardRef<HTMLDivElement, BoxProps>((props, ref) => (
1140
<Box
1241
{...props}
@@ -19,6 +48,7 @@ const StyledSidePanelWrapper = React.forwardRef<HTMLDivElement, BoxProps>((props
1948
paddingRight={["space0", "space40", "space40"]}
2049
width={["100%", "size40", "size40"]}
2150
height={props.height}
51+
boxSizing="content-box"
2252
/>
2353
));
2454

@@ -31,87 +61,148 @@ const config = {
3161
friction: 20,
3262
};
3363

34-
const transitionStyles = {
35-
from: { opacity: 0, transform: "translateX(100%)" },
36-
enter: { opacity: 1, transform: "translateX(0%)" },
37-
leave: { opacity: 0, transform: "translateX(100%)" },
38-
config,
39-
};
40-
41-
const mobileTransitionStyles = {
42-
from: { opacity: 0, transform: "translateY(100%)" },
43-
enter: { opacity: 1, transform: "translateY(0%)" },
44-
leave: { opacity: 0, transform: "translateY(100%)" },
45-
config,
46-
};
47-
48-
const SidePanel = React.forwardRef<HTMLDivElement, SidePanelProps>(
49-
({ element = "SIDE_PANEL", label, children, ...props }, ref) => {
50-
const { sidePanelId, isOpen } = React.useContext(SidePanelContext);
51-
52-
const { breakpointIndex } = useWindowSize();
53-
54-
const transitions =
55-
breakpointIndex === 0 ? useTransition(isOpen, mobileTransitionStyles) : useTransition(isOpen, transitionStyles);
56-
57-
const screenSize = window.innerHeight;
64+
interface SidePanelContentsProps extends SidePanelProps {
65+
sidePanelId: string;
66+
styles: any;
67+
isMobile: boolean;
68+
}
5869

70+
const SidePanelContents = React.forwardRef<HTMLDivElement, SidePanelContentsProps>(
71+
({ label, element, sidePanelId, styles, isMobile, children, ...props }, ref) => {
72+
// Get the offset of the side panel from the top of the viewport
5973
const sidePanelRef = React.useRef<HTMLDivElement>(null);
6074
const mergedSidePanelRef = useMergeRefs(sidePanelRef, ref) as React.RefObject<HTMLDivElement>;
61-
75+
const screenSize = window.innerHeight;
6276
const [offsetY, setOffsetY] = React.useState(0);
63-
64-
// Get the offset of the side panel from the top of the viewport
6577
React.useEffect(() => {
6678
const boundingClientRect = sidePanelRef?.current?.getBoundingClientRect();
6779
setOffsetY(boundingClientRect?.y || 0);
6880
}, []);
6981

82+
return (
83+
<Box
84+
{...safelySpreadBoxProps(props)}
85+
position="absolute"
86+
role="dialog"
87+
as={isMobile ? (ModalDialogPrimitiveContent as any) : "div"}
88+
aria-label={label}
89+
top={0}
90+
right={0}
91+
width={["100%", "auto", "auto"]}
92+
height="100%"
93+
element={element}
94+
id={sidePanelId}
95+
>
96+
<AnimatedStyledSidePanelWrapper
97+
ref={mergedSidePanelRef}
98+
element={`ANIMATED_${element}_WRAPPER`}
99+
style={styles}
100+
height={["100%", screenSize - offsetY, screenSize - offsetY]}
101+
top={[0, offsetY, offsetY]}
102+
position="relative"
103+
overflow="hidden"
104+
>
105+
<Box
106+
display="flex"
107+
flexDirection="column"
108+
width={["100%", "388px", "388px"]} // 400px - 12px
109+
position="absolute"
110+
top={0}
111+
left={[0, 12, 12]}
112+
bottom={0}
113+
borderStyle="solid"
114+
borderBottomLeftRadius={["borderRadius0", "borderRadius70", "borderRadius70"]}
115+
borderBottomRightRadius={["borderRadius0", "borderRadius70", "borderRadius70"]}
116+
borderTopRightRadius={["borderRadius60", "borderRadius70", "borderRadius70"]}
117+
borderTopLeftRadius={["borderRadius60", "borderRadius70", "borderRadius70"]}
118+
borderWidth="borderWidth10"
119+
borderColor="colorBorderWeaker"
120+
backgroundColor="colorBackgroundBody"
121+
marginTop={["space100", "space40", "space40"]}
122+
marginBottom={["space0", "space40", "space40"]}
123+
paddingBottom="space70"
124+
element={`INNER_${element}`}
125+
>
126+
{children}
127+
</Box>
128+
</AnimatedStyledSidePanelWrapper>
129+
</Box>
130+
);
131+
},
132+
);
133+
SidePanelContents.displayName = "SidePanelContents";
134+
135+
const SidePanel = React.forwardRef<HTMLDivElement, SidePanelProps>(
136+
({ element = "SIDE_PANEL", label, children, ...props }, ref) => {
137+
const theme = useTheme();
138+
const { sidePanelId, isOpen } = React.useContext(SidePanelContext);
139+
// Determine whether this is the initial render in order to block enter animations
140+
const [isFirstRender, setIsFirstRender] = React.useState(true);
141+
React.useEffect(() => {
142+
if (isFirstRender) {
143+
setIsFirstRender(false);
144+
}
145+
}, [isFirstRender]);
146+
147+
// Define transition styles for both breakpoints
148+
const transitionStyles = {
149+
from: isFirstRender ? undefined : { opacity: 0, width: "0px" },
150+
enter: { opacity: 1, width: "400px" },
151+
leave: { opacity: 0, width: "0px" },
152+
config,
153+
};
154+
const mobileTransitionStyles = {
155+
from: isFirstRender ? undefined : { opacity: 0, transform: "translateY(100%)" },
156+
enter: { opacity: 1, transform: "translateY(0%)" },
157+
leave: { opacity: 0, transform: "translateY(100%)" },
158+
config,
159+
};
160+
161+
// Set mobile or desktop transitions based on breakpointIndex
162+
const { breakpointIndex } = useWindowSize();
163+
const desktopTransitions = useTransition(isOpen, transitionStyles);
164+
const mobileTransitions = useTransition(isOpen, mobileTransitionStyles);
165+
const transitions = React.useMemo(() => {
166+
if (breakpointIndex === 0) return mobileTransitions;
167+
return desktopTransitions;
168+
}, [breakpointIndex, desktopTransitions, mobileTransitions]);
169+
70170
return (
71171
<>
72172
{transitions(
73173
(styles, item) =>
74-
item && (
75-
<Box
76-
{...safelySpreadBoxProps(props)} // moved this from animated wrapper... might cause something
77-
position="absolute"
78-
role="dialog"
79-
aria-label={label}
80-
top={0}
81-
right={0}
82-
width={["100%", "auto", "auto"]}
83-
height="100%"
84-
element={element}
85-
id={sidePanelId}
174+
item &&
175+
(breakpointIndex === 0 ? (
176+
<SidePanelMobileOverlay
177+
theme={theme}
178+
data-paste-element={`${element}_OVERLAY`}
179+
style={{ opacity: styles.opacity }}
86180
>
87-
<AnimatedStyledSidePanelWrapper
88-
ref={mergedSidePanelRef}
89-
element={`ANIMATED_${element}_WRAPPER`}
90-
style={styles}
91-
height={screenSize - offsetY}
92-
top={offsetY}
181+
<SidePanelContents
182+
{...props}
183+
element={element}
184+
sidePanelId={sidePanelId}
185+
styles={styles}
186+
label={label}
187+
isMobile
188+
ref={ref}
93189
>
94-
<Box
95-
display="flex"
96-
maxHeight="100%"
97-
flexDirection="column"
98-
width={["100%", "size40", "size40"]}
99-
borderStyle="solid"
100-
borderRadius={["borderRadius0", "borderRadius70", "borderRadius70"]}
101-
borderWidth="borderWidth10"
102-
borderColor="colorBorderWeaker"
103-
backgroundColor="colorBackgroundBody"
104-
marginTop="space40"
105-
marginBottom={["space0", "space40", "space40"]}
106-
paddingBottom="space70"
107-
overflowY="hidden"
108-
element={`INNER_${element}`}
109-
>
110-
{children}
111-
</Box>
112-
</AnimatedStyledSidePanelWrapper>
113-
</Box>
114-
),
190+
{children}
191+
</SidePanelContents>
192+
</SidePanelMobileOverlay>
193+
) : (
194+
<SidePanelContents
195+
{...props}
196+
element={element}
197+
sidePanelId={sidePanelId}
198+
styles={styles}
199+
label={label}
200+
isMobile={false}
201+
ref={ref}
202+
>
203+
{children}
204+
</SidePanelContents>
205+
)),
115206
)}
116207
</>
117208
);

packages/paste-core/components/side-panel/src/SidePanelFooter.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const SidePanelFooter = React.forwardRef<HTMLDivElement, SidePanelFooterProps>(
1111
paddingX={variant === "chat" ? "space50" : "space70"}
1212
paddingBottom="space50"
1313
paddingTop={variant === "chat" ? "space0" : "space50"}
14-
boxShadow={variant === "chat" ? "none" : "shadow"}
14+
boxShadow={variant === "chat" ? "none" : "shadowElevationTop05"}
1515
marginBottom="spaceNegative70"
1616
zIndex="zIndex20"
1717
display="flex"

packages/paste-core/components/side-panel/src/SidePanelHeader.tsx

+1-9
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,7 @@ import { CloseIcon } from "@twilio-paste/icons/esm/CloseIcon";
44
import * as React from "react";
55

66
import { SidePanelContext } from "./SidePanelContext";
7-
import type { SidePanelHeaderProps } from "./types";
8-
9-
type SidePanelCloseButtonProps = {
10-
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
11-
i18nCloseSidePanelTitle: string;
12-
sidePanelId: string;
13-
isOpen: boolean;
14-
element: string;
15-
};
7+
import type { SidePanelCloseButtonProps, SidePanelHeaderProps } from "./types";
168

179
const SidePanelCloseButton: React.FC<React.PropsWithChildren<SidePanelCloseButtonProps>> = ({
1810
setIsOpen,

packages/paste-core/components/side-panel/src/SidePanelPushContentWrapper.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export const SidePanelPushContentWrapper = React.forwardRef<HTMLDivElement, Side
3434
{...safelySpreadBoxProps(props)}
3535
ref={ref}
3636
// when using side panels in responsive layouts, we don't want any left margin in small screen, or initial SSR render situations. So basically never apply it in those situations
37-
style={breakpointIndex === (undefined || 0) ? undefined : styles}
37+
style={breakpointIndex === undefined || breakpointIndex === 0 ? undefined : styles}
3838
marginRight={["space0", theme.sizes.size40]}
3939
minWidth="size0"
4040
element={element}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import * as React from "react";
2+
3+
import type { SidePanelStateReturn, UseSidePanelStateProps } from "./types";
4+
5+
export const useSidePanelState = ({ open = false }: UseSidePanelStateProps = {}): SidePanelStateReturn => {
6+
const [isOpen, setIsOpen] = React.useState(open);
7+
8+
return {
9+
isOpen,
10+
setIsOpen,
11+
};
12+
};

packages/paste-core/components/side-panel/src/index.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,7 @@ export type {
1717
SidePanelContainerProps,
1818
SidePanelBodyProps,
1919
SidePanelFooterProps,
20+
SidePanelStateReturn,
2021
} from "./types";
2122
export { SidePanelContext } from "./SidePanelContext";
23+
export { useSidePanelState } from "./hooks";

packages/paste-core/components/side-panel/src/types.ts

+30
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,33 @@ export interface SidePanelContextProps {
175175
i18nCloseSidePanelTitle: string;
176176
i18nOpenSidePanelTitle: string;
177177
}
178+
179+
export interface SidePanelStateReturn {
180+
/**
181+
* State for the Side Panel. Determines whether the Side Panel is open or closed.
182+
*
183+
* @type {boolean}
184+
* @default false
185+
* @memberof SidePanelStateReturn
186+
*/
187+
isOpen: boolean;
188+
/**
189+
* Sets the state of the Side Panel between open and closed.
190+
*
191+
* @type {React.Dispatch<React.SetStateAction<boolean>>}
192+
* @memberof SidePanelStateReturn
193+
*/
194+
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
195+
}
196+
197+
export interface UseSidePanelStateProps {
198+
open?: boolean;
199+
}
200+
201+
export type SidePanelCloseButtonProps = {
202+
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
203+
i18nCloseSidePanelTitle: string;
204+
sidePanelId: string;
205+
isOpen: boolean;
206+
element: string;
207+
};

0 commit comments

Comments
 (0)