Skip to content

Commit

Permalink
feat(side-panel): create package (#3827)
Browse files Browse the repository at this point in the history
  • Loading branch information
nkrantz authored May 22, 2024
1 parent 8a4eeb6 commit bb89382
Show file tree
Hide file tree
Showing 29 changed files with 14,340 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .changeset/olive-swans-think.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@twilio-paste/side-panel": major
"@twilio-paste/core": minor
---

[Side Panel] add new Side Panel component. Side panel is a container that pushes the main page content when open.
5 changes: 5 additions & 0 deletions .changeset/seven-steaks-relate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@twilio-paste/codemods": minor
---

[codemods] add new package: Side Panel
1 change: 1 addition & 0 deletions .codesandbox/ci.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
"/packages/paste-core/components/separator",
"/packages/paste-core/primitives/sibling-box",
"/packages/paste-core/components/side-modal",
"/packages/paste-core/components/side-panel",
"/packages/paste-core/components/sidebar",
"/packages/paste-core/components/skeleton-loader",
"/packages/paste-core/components/slider",
Expand Down
9 changes: 9 additions & 0 deletions packages/paste-codemods/tools/.cache/mappings.json
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,15 @@
"SideModalHeader": "@twilio-paste/core/side-modal",
"SideModalHeading": "@twilio-paste/core/side-modal",
"useSideModalState": "@twilio-paste/core/side-modal",
"SidePanel": "@twilio-paste/core/side-panel",
"SidePanelBadgeButton": "@twilio-paste/core/side-panel",
"SidePanelBody": "@twilio-paste/core/side-panel",
"SidePanelButton": "@twilio-paste/core/side-panel",
"SidePanelContainer": "@twilio-paste/core/side-panel",
"SidePanelContext": "@twilio-paste/core/side-panel",
"SidePanelHeader": "@twilio-paste/core/side-panel",
"SidePanelHeaderActions": "@twilio-paste/core/side-panel",
"SidePanelPushContentWrapper": "@twilio-paste/core/side-panel",
"Sidebar": "@twilio-paste/core/sidebar",
"SidebarBetaBadge": "@twilio-paste/core/sidebar",
"SidebarBody": "@twilio-paste/core/sidebar",
Expand Down
107 changes: 107 additions & 0 deletions packages/paste-core/components/side-panel/__tests__/index.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Button } from "@twilio-paste/button";
import { Theme } from "@twilio-paste/theme";
import * as React from "react";

import {
SidePanel,
SidePanelBody,
SidePanelContainer,
SidePanelHeader,
SidePanelHeaderActions,
SidePanelPushContentWrapper,
} from "../src";

const MockSidePanel = ({ element = "SIDE_PANEL" }: { element?: string }): JSX.Element => {
const [isOpen, setIsOpen] = React.useState(true);
return (
<Theme.Provider theme="twilio" data-testid="wrapper">
<SidePanelContainer
sidePanelId="side-panel-id"
element={`${element}_CONTAINER`}
setIsOpen={setIsOpen}
isOpen={isOpen}
>
<SidePanel label="my side panel" element={element}>
<SidePanelHeader element={`${element}_HEADER`}>
Heading
<SidePanelHeaderActions element={`${element}_HEADER_ACTIONS`}>
<Button data-testid="action-button" variant="secondary_icon">
...
</Button>
</SidePanelHeaderActions>
</SidePanelHeader>
<SidePanelBody element={`${element}_BODY`}>Body</SidePanelBody>
</SidePanel>
<SidePanelPushContentWrapper element={`${element}_PUSH_CONTENT_WRAPPER`}>
Page content
</SidePanelPushContentWrapper>
</SidePanelContainer>
</Theme.Provider>
);
};

describe("SidePanel", () => {
beforeEach(() => {
render(<MockSidePanel />);
});
it("should render", () => {
expect(screen.getByRole("dialog")).toBeDefined();
});
it("should be removed from the DOM when isOpen=false", async () => {
const closeButton = screen
.getByRole("dialog")
.querySelector('[data-paste-element="SIDE_PANEL_HEADER_CLOSE_BUTTON"]');
if (closeButton) await userEvent.click(closeButton);
// r-t-l act() necessary for testing with react 17
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
});
expect(screen.queryByRole("dialog")).toBeNull(); // queryByRole returns null if no element is found whereas getByRole throws an error
});
it("should have an id", () => {
expect(screen.getByRole("dialog").getAttribute("id")).toEqual("side-panel-id");
});
it("should have an aria label", () => {
expect(screen.getByLabelText("my side panel")).toBeInTheDocument();
});
});

describe("Customization", () => {
it("should set default element values", () => {
render(<MockSidePanel />);
const sidePanelWrapper = screen.getByTestId("wrapper");
const sidePanel = screen.getByRole("dialog");
expect(sidePanelWrapper.querySelector('[data-paste-element="SIDE_PANEL_CONTAINER"]')).toBeInTheDocument();
expect(
sidePanelWrapper.querySelector('[data-paste-element="SIDE_PANEL_PUSH_CONTENT_WRAPPER"]'),
).toBeInTheDocument();
expect(sidePanel.getAttribute("data-paste-element")).toEqual("SIDE_PANEL");
expect(sidePanel.querySelector('[data-paste-element="ANIMATED_SIDE_PANEL_WRAPPER"]')).toBeInTheDocument();
expect(sidePanel.querySelector('[data-paste-element="INNER_SIDE_PANEL"]')).toBeInTheDocument();
expect(screen.getByText("Heading").getAttribute("data-paste-element")).toBe("SIDE_PANEL_HEADER");
expect(screen.getByText("...").parentElement?.parentElement?.getAttribute("data-paste-element")).toBe(
"SIDE_PANEL_HEADER_ACTIONS",
);
expect(screen.getByText("Body").getAttribute("data-paste-element")).toBe("SIDE_PANEL_BODY");
expect(screen.getByText("Page content").getAttribute("data-paste-element")).toBe("SIDE_PANEL_PUSH_CONTENT_WRAPPER");
});

it("should set custom element values", () => {
render(<MockSidePanel element="FTP" />);
const sidePanelWrapper = screen.getByTestId("wrapper");
const sidePanel = screen.getByRole("dialog");
expect(sidePanelWrapper.querySelector('[data-paste-element="FTP_CONTAINER"]')).toBeInTheDocument();
expect(sidePanelWrapper.querySelector('[data-paste-element="FTP_PUSH_CONTENT_WRAPPER"]')).toBeInTheDocument();
expect(sidePanel.getAttribute("data-paste-element")).toEqual("FTP");
expect(sidePanel.querySelector('[data-paste-element="ANIMATED_FTP_WRAPPER"]')).toBeInTheDocument();
expect(sidePanel.querySelector('[data-paste-element="INNER_FTP"]')).toBeInTheDocument();
expect(screen.getByText("Heading").getAttribute("data-paste-element")).toBe("FTP_HEADER");
expect(screen.getByText("...").parentElement?.parentElement?.getAttribute("data-paste-element")).toBe(
"FTP_HEADER_ACTIONS",
);
expect(screen.getByText("Body").getAttribute("data-paste-element")).toBe("FTP_BODY");
expect(screen.getByText("Page content").getAttribute("data-paste-element")).toBe("FTP_PUSH_CONTENT_WRAPPER");
});
});
3 changes: 3 additions & 0 deletions packages/paste-core/components/side-panel/build.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const { build } = require("../../../../tools/build/esbuild");

build(require("./package.json"));
75 changes: 75 additions & 0 deletions packages/paste-core/components/side-panel/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
{
"name": "@twilio-paste/side-panel",
"version": "0.0.0",
"category": "layout",
"status": "production",
"description": "Side Panel is a container that pushes the main page content when open.",
"author": "Twilio Inc.",
"license": "MIT",
"main:dev": "src/index.tsx",
"main": "dist/index.js",
"module": "dist/index.es.js",
"types": "dist/index.d.ts",
"sideEffects": false,
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"scripts": {
"build": "yarn clean && NODE_ENV=production node build.js && tsc",
"build:js": "NODE_ENV=development node build.js",
"build:typedocs": "tsx ../../../../tools/build/generate-type-docs",
"clean": "rm -rf ./dist",
"tsc": "tsc"
},
"peerDependencies": {
"@twilio-paste/anchor": "^12.1.0",
"@twilio-paste/animation-library": "^2.0.0",
"@twilio-paste/badge": "^8.2.0",
"@twilio-paste/box": "^10.2.0",
"@twilio-paste/button": "^14.1.0",
"@twilio-paste/color-contrast-utils": "^5.0.0",
"@twilio-paste/customization": "^8.1.1",
"@twilio-paste/design-tokens": "^10.3.0",
"@twilio-paste/icons": "^12.4.0",
"@twilio-paste/spinner": "^14.1.2",
"@twilio-paste/stack": "^8.1.0",
"@twilio-paste/style-props": "^9.1.1",
"@twilio-paste/styling-library": "^3.0.0",
"@twilio-paste/theme": "^11.0.1",
"@twilio-paste/types": "^6.0.0",
"@twilio-paste/uid-library": "^2.0.0",
"@twilio-paste/utils": "^5.0.0",
"@types/react": "^16.8.6 || ^17.0.2 || ^18.0.27",
"@types/react-dom": "^16.8.6 || ^17.0.2 || ^18.0.10",
"react": "^16.8.6 || ^17.0.2 || ^18.0.0",
"react-dom": "^16.8.6 || ^17.0.2 || ^18.0.0"
},
"devDependencies": {
"@twilio-paste/anchor": "^12.1.0",
"@twilio-paste/animation-library": "^2.0.0",
"@twilio-paste/badge": "^8.2.0",
"@twilio-paste/box": "^10.2.0",
"@twilio-paste/button": "^14.1.0",
"@twilio-paste/color-contrast-utils": "^5.0.0",
"@twilio-paste/customization": "^8.1.1",
"@twilio-paste/design-tokens": "^10.3.0",
"@twilio-paste/icons": "^12.4.0",
"@twilio-paste/spinner": "^14.1.2",
"@twilio-paste/stack": "^8.1.0",
"@twilio-paste/style-props": "^9.1.1",
"@twilio-paste/styling-library": "^3.0.0",
"@twilio-paste/theme": "^11.0.1",
"@twilio-paste/types": "^6.0.0",
"@twilio-paste/uid-library": "^2.0.0",
"@twilio-paste/utils": "^5.0.0",
"@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"tsx": "^4.0.0",
"typescript": "^4.9.4"
}
}
123 changes: 123 additions & 0 deletions packages/paste-core/components/side-panel/src/SidePanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { animated, useTransition } from "@twilio-paste/animation-library";
import { Box, safelySpreadBoxProps } from "@twilio-paste/box";
import type { BoxProps } from "@twilio-paste/box";
import { useMergeRefs, useWindowSize } from "@twilio-paste/utils";
import * as React from "react";

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

const StyledSidePanelWrapper = React.forwardRef<HTMLDivElement, BoxProps>((props, ref) => (
<Box
{...props}
display="flex"
ref={ref}
position="sticky"
zIndex="zIndex30"
top={props.top}
right={0}
paddingRight={["space0", "space40", "space40"]}
width={["100%", "size40", "size40"]}
height={props.height}
/>
));

StyledSidePanelWrapper.displayName = "StyledSidePanelWrapper";
const AnimatedStyledSidePanelWrapper = animated(StyledSidePanelWrapper);

const config = {
mass: 0.3,
tension: 288,
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<HTMLDivElement, SidePanelProps>(
({ 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;

const sidePanelRef = React.useRef<HTMLDivElement>(null);
const mergedSidePanelRef = useMergeRefs(sidePanelRef, ref) as React.RefObject<HTMLDivElement>;

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 (
<>
{transitions(
(styles, item) =>
item && (
<Box
{...safelySpreadBoxProps(props)} // moved this from animated wrapper... might cause something
position="absolute"
role="dialog"
aria-label={label}
top={0}
right={0}
width={["100%", "auto", "auto"]}
height="100%"
element={element}
id={sidePanelId}
>
<AnimatedStyledSidePanelWrapper
ref={mergedSidePanelRef}
element={`ANIMATED_${element}_WRAPPER`}
style={styles}
height={screenSize - offsetY}
top={offsetY}
>
<Box
display="flex"
maxHeight="100%"
flexDirection="column"
width={["100%", "size40", "size40"]}
borderStyle="solid"
borderRadius={["borderRadius0", "borderRadius70", "borderRadius70"]}
borderWidth="borderWidth10"
borderColor="colorBorderWeaker"
backgroundColor="colorBackgroundBody"
marginTop="space40"
marginBottom={["space0", "space40", "space40"]}
paddingBottom="space70"
overflowY="hidden"
element={`INNER_${element}`}
>
{children}
</Box>
</AnimatedStyledSidePanelWrapper>
</Box>
),
)}
</>
);
},
);

SidePanel.displayName = "SidePanel";

export { SidePanel };
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Badge } from "@twilio-paste/badge";
import * as React from "react";

import { SidePanelContext } from "./SidePanelContext";
import type { ButtonBadgeProps, SidePanelBadgeButtonProps } from "./types";

const ButtonBadge = React.forwardRef<HTMLButtonElement, ButtonBadgeProps>(function ButtonBadge(
{ children, ...props },
ref,
) {
return (
<Badge {...props} as="button" ref={ref}>
{children}
</Badge>
);
});

ButtonBadge.displayName = "ButtonBadge";

const SidePanelBadgeButton = React.forwardRef<HTMLButtonElement, SidePanelBadgeButtonProps>(
({ children, element = "SIDE_PANEL_BADGE", ...sidePanelButtonProps }, ref) => {
const { i18nCloseSidePanelTitle, i18nOpenSidePanelTitle, isOpen, setIsOpen, sidePanelId } =
React.useContext(SidePanelContext);

return (
<ButtonBadge
{...sidePanelButtonProps}
as="button"
element={element}
aria-label={isOpen ? i18nCloseSidePanelTitle : i18nOpenSidePanelTitle}
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-controls={sidePanelId}
ref={ref}
>
{children}
</ButtonBadge>
);
},
);

SidePanelBadgeButton.displayName = "SidePanelBadgeButton";
export { SidePanelBadgeButton };
Loading

0 comments on commit bb89382

Please sign in to comment.