generated from cloudoperators/repository-template
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore(ui): migrate Panel, PanelBody and PanelFooter components to Typ…
…escript (#606) * chore(ui): initial conversion of Panel components to Typescript * chore(ui): fix types, refactor and add edge cases for panel footer and body * chore(ui): generate changeset, remove deprecated stories, refactor components * chore(ui): remove deprecated .js components * chore(ui): export deprecated Panel and undo some chnages * chore(ui): fix Panel story * chore(ui): fix stories * chore(ui): fix stories * chore(ui): fix Panel * chore(ui): fix stories types * chore(ui): fix stories types * reuse size prop --------- Co-authored-by: Andreas Pfau <[email protected]>
- Loading branch information
1 parent
8e6e94d
commit 5d186c7
Showing
23 changed files
with
624 additions
and
43 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@cloudoperators/juno-ui-components": minor | ||
--- | ||
|
||
Migrate Panel, PanelBody and PanelFooter components to TypeScript |
160 changes: 160 additions & 0 deletions
160
packages/ui-components/src/components/Panel/Panel.component.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,160 @@ | ||
/* | ||
* SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors | ||
* SPDX-License-Identifier: Apache-2.0 | ||
*/ | ||
|
||
import React, { useState, useEffect, useRef, FC, HTMLAttributes, ReactNode, MouseEvent } from "react" | ||
import { createPortal } from "react-dom" | ||
|
||
import { Icon } from "../Icon/Icon.component" | ||
import { usePortalRef } from "../PortalProvider/PortalProvider.component" | ||
|
||
const panelStyles = (isOpen: boolean, isTransitioning: boolean, size?: PanelSize): string => { | ||
return ` | ||
jn-fixed | ||
jn-right-0 | ||
jn-transition-transform | ||
jn-ease-out | ||
jn-duration-300 | ||
jn-inset-y-0 | ||
jn-z-[9989] | ||
jn-grid | ||
jn-grid-rows-[auto_1fr] | ||
jn-bg-theme-panel | ||
jn-backdrop-blur | ||
jn-backdrop-saturate-150 | ||
jn-shadow-md | ||
${ | ||
size === "large" | ||
? ` | ||
jn-w-[90%] | ||
xl:jn-w-[80%] | ||
2xl:jn-w-[1228px]` | ||
: ` | ||
jn-w-[75%] | ||
xl:jn-w-[55%] | ||
2xl:jn-w-[844px]` | ||
} | ||
${!isOpen ? `jn-translate-x-[100%]` : ""} | ||
${!isOpen && !isTransitioning ? `jn-invisible` : ""} | ||
` | ||
.replace(/\n/g, " ") | ||
.replace(/\s+/g, " ") | ||
} | ||
|
||
const contentWrapperStyles = `jn-overflow-auto` | ||
|
||
const panelHeaderStyles = ` | ||
jn-flex | ||
jn-items-center | ||
jn-py-4 | ||
jn-px-8 | ||
` | ||
|
||
const panelTitleStyles = ` | ||
jn-text-theme-high | ||
jn-text-lg | ||
jn-font-bold | ||
` | ||
|
||
type PanelSize = "default" | "large" | ||
|
||
export interface PanelProps extends HTMLAttributes<HTMLDivElement> { | ||
/** | ||
* Title of the panel. | ||
*/ | ||
heading?: ReactNode | ||
/** | ||
* Size of the opened panel. | ||
*/ | ||
size?: PanelSize | ||
/** | ||
* Controls whether the panel is open and visible. | ||
*/ | ||
opened?: boolean | ||
/** | ||
* Determines whether the panel can be closed using a close button. | ||
*/ | ||
closeable?: boolean | ||
/** | ||
* Handler called when the close button is clicked. | ||
*/ | ||
// eslint-disable-next-line no-unused-vars | ||
onClose?: (event: MouseEvent<HTMLElement>) => void | ||
/** | ||
* Additional CSS classes to apply to the panel for custom styling. | ||
*/ | ||
className?: string | ||
/** | ||
* Content to be rendered inside the main body of the panel. | ||
*/ | ||
children?: ReactNode | ||
} | ||
|
||
/** | ||
* A Panel component that slides in from the right side of the screen. | ||
* It can be used to display additional content/controls for the content area. | ||
*/ | ||
export const Panel: FC<PanelProps> = ({ | ||
heading = "", | ||
size = "default", | ||
opened = false, | ||
closeable = true, | ||
onClose, | ||
className = "", | ||
children, | ||
...props | ||
}) => { | ||
const [isOpen, setIsOpen] = useState(opened) | ||
const [isCloseable, setIsCloseable] = useState(closeable) | ||
const [isTransitioning, setIsTransitioning] = useState(false) | ||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) | ||
const portalContainer = usePortalRef() | ||
|
||
// Update if the 'opened' prop changes from outside | ||
useEffect(() => setIsOpen(opened), [opened]) | ||
|
||
// Update if the 'closeable' prop changes from outside | ||
useEffect(() => setIsCloseable(closeable), [closeable]) | ||
|
||
// Clear timeout when the component unmounts | ||
useEffect(() => { | ||
return () => { | ||
if (timeoutRef.current) clearTimeout(timeoutRef.current) | ||
} | ||
}, []) | ||
|
||
// ----- Timeout handling ----- | ||
// Set the panel to invisible only after the closing transition has finished (500ms). | ||
// This ensures the panel can't be tab-targeted when closed. | ||
useEffect(() => { | ||
if (!isOpen) { | ||
setIsTransitioning(true) | ||
if (timeoutRef.current) clearTimeout(timeoutRef.current) | ||
timeoutRef.current = setTimeout(() => setIsTransitioning(false), 500) | ||
} | ||
}, [isOpen]) | ||
|
||
const handleClose = (event: MouseEvent<HTMLElement>) => { | ||
setIsOpen(false) | ||
onClose?.(event) | ||
} | ||
|
||
return createPortal( | ||
<div | ||
className={`juno-panel ${panelStyles(isOpen, isTransitioning, size)} ${className}`} | ||
role="dialog" | ||
aria-labelledby="juno-panel-title" | ||
{...props} | ||
> | ||
<div className={`juno-panel-header ${panelHeaderStyles}`}> | ||
<div className={`juno-panel-title ${panelTitleStyles}`} id="juno-panel-title"> | ||
{heading} | ||
</div> | ||
{isCloseable && <Icon icon="close" onClick={handleClose} className="juno-panel-close jn-ml-auto" />} | ||
</div> | ||
<div className={`juno-panel-content-wrapper ${contentWrapperStyles}`}>{children}</div> | ||
</div>, | ||
portalContainer ? portalContainer : document.body | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
118 changes: 118 additions & 0 deletions
118
packages/ui-components/src/components/Panel/Panel.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,118 @@ | ||
/* | ||
* SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors | ||
* SPDX-License-Identifier: Apache-2.0 | ||
*/ | ||
|
||
import * as React from "react" | ||
import { render, screen, waitFor } from "@testing-library/react" | ||
import userEvent from "@testing-library/user-event" | ||
import { describe, expect, test, vi } from "vitest" | ||
|
||
import { Panel } from "./Panel.component" | ||
|
||
const closedClass = "jn-translate-x-[100%]" | ||
|
||
describe("Panel", () => { | ||
describe("Basic Rendering", () => { | ||
test("renders a panel", async () => { | ||
await waitFor(() => render(<Panel />)) | ||
expect(screen.getByRole("dialog")).toBeInTheDocument() | ||
expect(screen.getByRole("dialog")).toHaveClass("juno-panel") | ||
}) | ||
|
||
test("renders a closed panel by default", async () => { | ||
await waitFor(() => render(<Panel />)) | ||
expect(screen.getByRole("dialog")).toBeInTheDocument() | ||
expect(screen.getByRole("dialog")).toHaveClass(closedClass) | ||
}) | ||
|
||
test("renders a panel without any props", async () => { | ||
await waitFor(() => render(<Panel />)) | ||
expect(screen.getByRole("dialog")).toBeInTheDocument() | ||
}) | ||
|
||
test("renders an opened panel", async () => { | ||
await waitFor(() => render(<Panel opened />)) | ||
expect(screen.getByRole("dialog")).toBeInTheDocument() | ||
expect(screen.getByRole("dialog")).not.toHaveClass(closedClass) | ||
}) | ||
|
||
test("renders a panel with heading", async () => { | ||
await waitFor(() => render(<Panel heading="My heading" opened />)) | ||
expect(screen.getByRole("dialog")).toBeInTheDocument() | ||
expect(screen.getByRole("dialog")).toHaveTextContent("My heading") | ||
}) | ||
|
||
test("renders a custom classname", async () => { | ||
await waitFor(() => render(<Panel className="my-custom-classname" />)) | ||
expect(screen.getByRole("dialog")).toBeInTheDocument() | ||
expect(screen.getByRole("dialog")).toHaveClass("my-custom-classname") | ||
}) | ||
|
||
test("renders all props as passed", async () => { | ||
await waitFor(() => render(<Panel data-xyz={true} />)) | ||
expect(screen.getByRole("dialog")).toBeInTheDocument() | ||
expect(screen.getByRole("dialog")).toHaveAttribute("data-xyz") | ||
}) | ||
|
||
test("renders a panel with undefined className", async () => { | ||
await waitFor(() => render(<Panel className={undefined} />)) | ||
expect(screen.getByRole("dialog")).toBeInTheDocument() | ||
}) | ||
}) | ||
|
||
describe("Conditional Rendering", () => { | ||
test("renders a panel with close button by default", async () => { | ||
await waitFor(() => render(<Panel />)) | ||
expect(screen.getByRole("dialog")).toBeInTheDocument() | ||
expect(screen.getByRole("button")).toBeInTheDocument() | ||
expect(screen.getByLabelText("close")).toBeInTheDocument() | ||
expect(screen.getByRole("button")).toHaveAttribute("aria-label", "close") | ||
expect(screen.getByRole("img")).toHaveAttribute("alt", "close") | ||
}) | ||
|
||
test("renders a panel without a close button", async () => { | ||
await waitFor(() => render(<Panel closeable={false} />)) | ||
expect(screen.getByRole("dialog")).toBeInTheDocument() | ||
expect(screen.queryByRole("button")).not.toBeInTheDocument() | ||
expect(screen.queryByLabelText("close")).not.toBeInTheDocument() | ||
}) | ||
|
||
test("renders a panel without a heading", async () => { | ||
await waitFor(() => render(<Panel opened />)) | ||
expect(screen.getByRole("dialog")).toBeInTheDocument() | ||
expect(screen.getByRole("dialog")).not.toHaveTextContent("My heading") | ||
}) | ||
}) | ||
|
||
describe("Events", () => { | ||
test("on click on close button closes panel", async () => { | ||
await waitFor(() => render(<Panel />)) | ||
await waitFor(() => userEvent.click(screen.getByRole("button"))) | ||
expect(screen.getByRole("dialog")).toHaveClass(closedClass) | ||
}) | ||
|
||
test("on click on close button fires onClose handler as passed", async () => { | ||
const handleClose = vi.fn() | ||
await waitFor(() => render(<Panel onClose={handleClose} />)) | ||
await waitFor(() => userEvent.click(screen.getByRole("button"))) | ||
expect(handleClose).toHaveBeenCalledTimes(1) | ||
}) | ||
|
||
test("on click on close button when panel is already closed", async () => { | ||
const handleClose = vi.fn() | ||
await waitFor(() => render(<Panel opened={false} onClose={handleClose} />)) | ||
const button = screen.queryByRole("button") | ||
await waitFor(() => (button ? userEvent.click(button) : null)) | ||
expect(screen.getByRole("dialog")).toHaveClass(closedClass) | ||
expect(handleClose).toHaveBeenCalledTimes(1) | ||
}) | ||
|
||
test("double-click on close button fires onClose handler twice", async () => { | ||
const handleClose = vi.fn() | ||
await waitFor(() => render(<Panel onClose={handleClose} />)) | ||
await waitFor(() => userEvent.dblClick(screen.getByRole("button"))) | ||
expect(handleClose).toHaveBeenCalledTimes(2) | ||
}) | ||
}) | ||
}) |
File renamed without changes.
43 changes: 43 additions & 0 deletions
43
packages/ui-components/src/components/PanelBody/PanelBody.component.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
/* | ||
* SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors | ||
* SPDX-License-Identifier: Apache-2.0 | ||
*/ | ||
|
||
import React, { ReactNode, HTMLAttributes } from "react" | ||
|
||
const bodyContentStyles = ` | ||
jn-px-8 | ||
jn-py-4 | ||
` | ||
|
||
export interface PanelBodyProps extends HTMLAttributes<HTMLDivElement> { | ||
/** | ||
* Additional CSS classes to apply to the panel body for custom styling. | ||
*/ | ||
className?: string | ||
|
||
/** | ||
* The content to be rendered inside the panel body. | ||
* Typically, this will include form elements and other interactive content. | ||
*/ | ||
children?: ReactNode | ||
|
||
/** | ||
* Optional footer component to be rendered below the main content. | ||
* The footer can include buttons or other control elements. | ||
*/ | ||
footer?: React.ReactElement | ||
} | ||
|
||
/** | ||
* A PanelBody component is used to encapsulate the main content of a panel. | ||
* The primary content for the panel, such as forms or information, is rendered here. | ||
*/ | ||
export const PanelBody: React.FC<PanelBodyProps> = ({ className = "", footer, children, ...props }) => { | ||
return ( | ||
<div className={`juno-panel-body ${className}`} {...props}> | ||
<div className={`juno-panel-body-content ${bodyContentStyles}`}>{children}</div> | ||
{footer} | ||
</div> | ||
) | ||
} |
Oops, something went wrong.