Skip to content

Commit

Permalink
chore(ui): migrate Panel, PanelBody and PanelFooter components to Typ…
Browse files Browse the repository at this point in the history
…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
guoda-puidokaite and andypf authored Nov 27, 2024
1 parent 8e6e94d commit 5d186c7
Show file tree
Hide file tree
Showing 23 changed files with 624 additions and 43 deletions.
5 changes: 5 additions & 0 deletions .changeset/tall-planets-wave.md
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 packages/ui-components/src/components/Panel/Panel.component.tsx
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
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,10 @@
*/

import React from "react"
import { Panel } from "./index.js"
import { PanelBody } from "../PanelBody/index.js"
import { ContentAreaWrapper } from "../ContentAreaWrapper/index.js"
import { ContentArea } from "../ContentArea/index.js"
import { PortalProvider } from "../../deprecated_js/PortalProvider/PortalProvider.component.js"
import { Meta, StoryFn } from "@storybook/react"
import { Panel, PanelProps } from "./Panel.component"
import { PanelBody } from "../PanelBody/PanelBody.component"
import { PortalProvider } from "../PortalProvider/PortalProvider.component"

// the decorator captures the panel's fixed positioning within the iframe. otherwise it would be placed relative to the viewport which is unwieldy in storybook
export default {
Expand All @@ -17,24 +16,32 @@ export default {
argTypes: {
children: {
control: false,
table: {
type: { summary: "ReactNode" },
},
},
heading: {
table: {
type: { summary: "ReactNode" },
},
},
},
decorators: [
(story) => (
(story: () => React.ReactNode) => (
<PortalProvider>
<div className="jn-contrast-100">{story()}</div>
</PortalProvider>
),
],
}
} as Meta

const Template = (args) => (
<ContentAreaWrapper>
const Template: StoryFn<PanelProps> = (args) => (
<div>
<Panel {...args}>
<PanelBody>Panel Body Content</PanelBody>
</Panel>
<ContentArea className="dummy-css-ignore jn-h-[150px]">Content Area</ContentArea>
</ContentAreaWrapper>
<div className="dummy-css-ignore jn-h-[150px]">Content Area</div>
</div>
)

export const WithHeading = {
Expand Down
118 changes: 118 additions & 0 deletions packages/ui-components/src/components/Panel/Panel.test.tsx
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)
})
})
})
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>
)
}
Loading

0 comments on commit 5d186c7

Please sign in to comment.