Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(ui): migrate Panel, PanelBody and PanelFooter components to Typescript #606

Merged
merged 19 commits into from
Nov 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e101cd2
chore(ui): initial conversion of Panel components to Typescript
guoda-puidokaite Nov 13, 2024
e194526
chore(ui): fix types, refactor and add edge cases for panel footer an…
guoda-puidokaite Nov 13, 2024
cbbbde0
chore(ui): generate changeset, remove deprecated stories, refactor co…
guoda-puidokaite Nov 13, 2024
2029083
Merge branch 'main' into guoda-convert-panel-to-ts
guoda-puidokaite Nov 13, 2024
f16db2c
chore(ui): remove deprecated .js components
guoda-puidokaite Nov 13, 2024
aad42b3
Merge branch 'main' into guoda-convert-panel-to-ts
guoda-puidokaite Nov 18, 2024
d70aa22
Merge branch 'main' into guoda-convert-panel-to-ts
guoda-puidokaite Nov 19, 2024
6e33c99
chore(ui): export deprecated Panel and undo some chnages
guoda-puidokaite Nov 25, 2024
d3604ea
chore(ui): fix Panel story
guoda-puidokaite Nov 25, 2024
d7f4d79
chore(ui): fix stories
guoda-puidokaite Nov 25, 2024
f0f52ea
Merge branch 'main' into guoda-convert-panel-to-ts
guoda-puidokaite Nov 25, 2024
4ec6819
chore(ui): fix stories
guoda-puidokaite Nov 25, 2024
31c7edd
chore(ui): fix Panel
guoda-puidokaite Nov 25, 2024
513a8c7
Merge branch 'main' into guoda-convert-panel-to-ts
guoda-puidokaite Nov 25, 2024
373931f
chore(ui): fix stories types
guoda-puidokaite Nov 25, 2024
f279780
chore(ui): fix stories types
guoda-puidokaite Nov 25, 2024
7d6ff79
Merge branch 'main' into guoda-convert-panel-to-ts
guoda-puidokaite Nov 26, 2024
6af2b12
reuse size prop
guoda-puidokaite Nov 27, 2024
260306f
Merge branch 'main' into guoda-convert-panel-to-ts
andypf Nov 27, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading