diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b550f94..ef57df12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,12 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `` - component for hiding elements in specific media +- `` + - `title` property now accepts a React component in addition to a string - `` - - force children to get displayed as inline content + - force children to get displayed as inline content - `` - - `useOnly` property: specify if only parts of the content should be used for the shortened preview, this property replaces `firstNonEmptyLineOnly` + - `useOnly` property: specify if only parts of the content should be used for the shortened preview, this property replaces `firstNonEmptyLineOnly` ### Fixed @@ -21,7 +23,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - create more whitespace inside `small` tag - reduce visual impact of border - `` - - take Markdown rendering into account before testing the maximum preview length + - take Markdown rendering into account before testing the maximum preview length - `` - header-menu items are vertically centered now @@ -41,7 +43,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Deprecated - `` - - `firstNonEmptyLineOnly` will be removed, is replaced by `useOnly="firstNonEmptyLine"` + - `firstNonEmptyLineOnly` will be removed, is replaced by `useOnly="firstNonEmptyLine"` ## [25.0.0] - 2025-12-01 diff --git a/src/components/ContentGroup/ContentGroup.stories.tsx b/src/components/ContentGroup/ContentGroup.stories.tsx index bf524e9b..edb0df2d 100644 --- a/src/components/ContentGroup/ContentGroup.stories.tsx +++ b/src/components/ContentGroup/ContentGroup.stories.tsx @@ -45,3 +45,8 @@ BasicExample.args = { ), }; + +export const TitleAsElement = BasicExample.bind({}); +TitleAsElement.args = { + title:
Title as element
, +}; diff --git a/src/components/ContentGroup/ContentGroup.tsx b/src/components/ContentGroup/ContentGroup.tsx index 4ce4172d..1015bc96 100644 --- a/src/components/ContentGroup/ContentGroup.tsx +++ b/src/components/ContentGroup/ContentGroup.tsx @@ -1,6 +1,7 @@ -import React from "react"; +import React, { ReactElement } from "react"; import classNames from "classnames"; import Color from "color"; +import { isString } from "lodash"; import { TestableComponent } from "../../components/interfaces"; import { CLASSPREFIX as eccgui } from "../../configuration/constants"; @@ -19,11 +20,13 @@ import { Tooltip, } from "../index"; +const MAX_HEADLINE_LEVEL = 6; + export interface ContentGroupProps extends Omit, "title">, TestableComponent { /** * Title of the content group. */ - title?: string; + title?: string | ReactElement; /** * Level of the content group. */ @@ -138,9 +141,17 @@ export const ContentGroup = ({ }, []); } - const contextInfoElements = Array.isArray(contextInfo) ? contextInfo : [contextInfo]; + const contextInfoElements = React.Children.toArray(contextInfo); const { className: contentClassName, ...otherContentProps } = contentProps ?? {}; + const headerLevel = Math.min(Math.max(minimumHeadlineLevel, level + minimumHeadlineLevel), MAX_HEADLINE_LEVEL); + const titleComponent = isString(title) + ? React.createElement(`h${headerLevel}`, { + children: {title}, + className: `${eccgui}-contentgroup__header__title`, + }) + : title; + const headerContent = displayHeader ? ( <> @@ -158,17 +169,7 @@ export const ContentGroup = ({ )} {title && ( - {React.createElement( - "h" + - Math.min( - Math.max(minimumHeadlineLevel, level + minimumHeadlineLevel), - 6 - ).toString(), - { - children: {title}, - className: `${eccgui}-contentgroup__header__title`, - } - )} + {titleComponent} {description && ( <> @@ -179,8 +180,8 @@ export const ContentGroup = ({ )} )} - {contextInfoElements && - contextInfoElements[0]?.props && + {contextInfoElements.length > 0 && + React.isValidElement(contextInfoElements[0]) && Object.values(contextInfoElements[0].props).every((v) => v !== undefined) && (
diff --git a/src/components/ContentGroup/tests/ContentGroup.test.tsx b/src/components/ContentGroup/tests/ContentGroup.test.tsx new file mode 100644 index 00000000..714fdae6 --- /dev/null +++ b/src/components/ContentGroup/tests/ContentGroup.test.tsx @@ -0,0 +1,475 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import "@testing-library/jest-dom"; + +import { ContentGroup } from "../../../../index"; +import { BasicExample } from "../ContentGroup.stories"; + +describe("ContentGroup", () => { + describe("basic rendering", () => { + it("should render with string title", () => { + const { container } = render(content); + + expect(container.querySelector(".eccgui-contentgroup")).toBeInTheDocument(); + expect(screen.getByText("test title")).toBeInTheDocument(); + expect(container.querySelector(".eccgui-contentgroup__header__title")).toBeInTheDocument(); + expect(screen.getByText("content")).toBeInTheDocument(); + }); + + it("should render with component title", () => { + const { container } = render( + test component title
}> + content +
+ ); + + expect(container.querySelector(".eccgui-contentgroup")).toBeInTheDocument(); + expect(screen.getByText("test component title")).toBeInTheDocument(); + expect(container.querySelector(".eccgui-contentgroup__header")).toBeInTheDocument(); + expect(container.querySelector(".eccgui-contentgroup__header__title")).not.toBeInTheDocument(); + expect(container.querySelector(".my-custom-classname")).toBeInTheDocument(); + expect(screen.getByText("content")).toBeInTheDocument(); + }); + + it("should render without title", () => { + const { container } = render(content only); + + expect(container.querySelector(".eccgui-contentgroup")).toBeInTheDocument(); + expect(screen.getByText("content only")).toBeInTheDocument(); + expect(container.querySelector(".eccgui-contentgroup__header")).not.toBeInTheDocument(); + }); + + it("should render with BasicExample story args", () => { + const { container } = render(); + + expect(container.querySelector(".eccgui-contentgroup")).toBeInTheDocument(); + expect(screen.getByText("Content group title")).toBeInTheDocument(); + }); + + it("should apply custom className", () => { + const { container } = render(content); + + const contentGroup = container.querySelector(".eccgui-contentgroup"); + expect(contentGroup).toHaveClass("custom-class"); + }); + + it("should pass data-testid attribute", () => { + const { container } = render( + + content + + ); + + expect(container.querySelector('[data-testid="content-group-test"]')).toBeInTheDocument(); + }); + }); + + describe("collapse functionality", () => { + it("should render toggle collapse button when handlerToggleCollapse is provided", () => { + const handleToggle = jest.fn(); + const { container } = render( + + content + + ); + + expect(container.querySelector(".eccgui-contentgroup__header__toggler")).toBeInTheDocument(); + }); + + it("should not render toggle button when handlerToggleCollapse is not provided", () => { + const { container } = render(Content); + + expect(container.querySelector(".eccgui-contentgroup__header__toggler")).not.toBeInTheDocument(); + }); + + it("should call handlerToggleCollapse when toggle button is clicked", () => { + const handleToggle = jest.fn(); + const { container } = render( + + Content + + ); + + const toggleButton = container.querySelector(".eccgui-contentgroup__header__toggler"); + fireEvent.click(toggleButton!); + + expect(handleToggle).toHaveBeenCalledTimes(1); + }); + + it("should hide content when isCollapsed is true", () => { + const handleToggle = jest.fn(); + const { container } = render( + +
Hidden Content
+
+ ); + + expect(container.querySelector(".eccgui-contentgroup__content")).not.toBeInTheDocument(); + expect(screen.queryByTestId("content")).not.toBeInTheDocument(); + }); + + it("should show content when isCollapsed is false", () => { + const handleToggle = jest.fn(); + const { container } = render( + +
Visible Content
+
+ ); + + expect(container.querySelector(".eccgui-contentgroup__content")).toBeInTheDocument(); + expect(screen.getByTestId("content")).toBeInTheDocument(); + }); + + it("should hide action options when collapsed", () => { + const handleToggle = jest.fn(); + const { container } = render( + Action} + > + Content + + ); + + expect(screen.queryByTestId("action-btn")).not.toBeInTheDocument(); + expect(container.querySelector(".eccgui-contentgroup__header__options")).not.toBeInTheDocument(); + }); + + it("should show action options when not collapsed", () => { + const handleToggle = jest.fn(); + const { container } = render( + Action} + > + Content + + ); + + expect(screen.getByTestId("action-btn")).toBeInTheDocument(); + expect(container.querySelector(".eccgui-contentgroup__header__options")).toBeInTheDocument(); + }); + + it("should use custom textToggleCollapse", () => { + const handleToggle = jest.fn(); + const { container } = render( + + Content + + ); + + const icon = container.querySelector(".eccgui-contentgroup__header__toggler svg"); + expect(icon).toHaveAttribute("description", "Toggle content"); + }); + + it("should use default 'Show less' text when not collapsed", () => { + const handleToggle = jest.fn(); + const { container } = render( + + Content + + ); + + const icon = container.querySelector(".eccgui-contentgroup__header__toggler svg"); + expect(icon).toHaveAttribute("description", "Show less"); + }); + + it("should use default 'Show more' text when collapsed", () => { + const handleToggle = jest.fn(); + const { container } = render( + + Content + + ); + + const icon = container.querySelector(".eccgui-contentgroup__header__toggler svg"); + expect(icon).toHaveAttribute("description", "Show more"); + }); + }); + + describe("border styling", () => { + it("should apply border-main class when borderMainConnection is true", () => { + const { container } = render( + + Content + + ); + + expect(container.querySelector(".eccgui-contentgroup--border-main")).toBeInTheDocument(); + }); + + it("should not apply border-main class when borderMainConnection is false", () => { + const { container } = render( + + Content + + ); + + expect(container.querySelector(".eccgui-contentgroup--border-main")).not.toBeInTheDocument(); + }); + + it("should apply border-sub class when borderSubConnection is true", () => { + const { container } = render( + + Content + + ); + + expect(container.querySelector(".eccgui-contentgroup--border-sub")).toBeInTheDocument(); + }); + + it("should apply border-sub class when borderSubConnection is an array of colors", () => { + const { container } = render( + + Content + + ); + + expect(container.querySelector(".eccgui-contentgroup--border-sub")).toBeInTheDocument(); + }); + + it("should set custom CSS property for gradient border when borderSubConnection is an array", () => { + const { container } = render( + + Content + + ); + + const section = container.querySelector(".eccgui-contentgroup"); + const style = (section as HTMLElement)?.style.getPropertyValue("--eccgui-color-contentgroup-border-sub"); + expect(style).toBeTruthy(); + }); + + it("should not set gradient border CSS property when borderSubConnection is boolean", () => { + const { container } = render( + + Content + + ); + + const section = container.querySelector(".eccgui-contentgroup"); + const style = (section as HTMLElement)?.style.getPropertyValue("--eccgui-color-contentgroup-border-sub"); + expect(style).toBeFalsy(); + }); + }); + + describe("whitespace size", () => { + const sizes = ["tiny", "small", "medium", "large", "xlarge"] as const; + + sizes.forEach((size) => { + it(`should apply padding class for whitespaceSize="${size}"`, () => { + const { container } = render( + + Content + + ); + + expect(container.querySelector(`.eccgui-contentgroup--padding-${size}`)).toBeInTheDocument(); + }); + }); + }); + + describe("headline level", () => { + it("should render title with correct headline level", () => { + const { container } = render( + + Content + + ); + + expect(container.querySelector("h2")).toBeInTheDocument(); + }); + + it("should render title with maximum headline level of 6", () => { + const { container } = render( + + Content + + ); + + expect(container.querySelector("h6")).toBeInTheDocument(); + }); + + it("should render title with default minimumHeadlineLevel of 3", () => { + const { container } = render( + + Content + + ); + + expect(container.querySelector("h4")).toBeInTheDocument(); + }); + }); + + describe("context info", () => { + it("should render context info in header when title is present", () => { + const { container } = render( + Context Info}> + Content + + ); + + expect(screen.getByTestId("context")).toBeInTheDocument(); + expect(container.querySelector(".eccgui-contentgroup__header__context")).toBeInTheDocument(); + }); + + it("should render context info in content area when no title", () => { + const { container } = render( + Context Info}>Content + ); + + expect(screen.getByTestId("context")).toBeInTheDocument(); + expect(container.querySelector(".eccgui-contentgroup__content__context")).toBeInTheDocument(); + }); + + it("should render array of context info elements", () => { + render( + + Context 1 + , + + Context 2 + , + ]} + > + Content + + ); + + expect(screen.getByTestId("context-1")).toBeInTheDocument(); + expect(screen.getByTestId("context-2")).toBeInTheDocument(); + }); + }); + + describe("action options", () => { + it("should render action options in header when title is present", () => { + const { container } = render( + Action}> + Content + + ); + + expect(screen.getByTestId("action")).toBeInTheDocument(); + expect(container.querySelector(".eccgui-contentgroup__header__options")).toBeInTheDocument(); + }); + + it("should render action options in content area when no title", () => { + const { container } = render( + Action}>Content + ); + + expect(screen.getByTestId("action")).toBeInTheDocument(); + expect(container.querySelector(".eccgui-contentgroup__content__options")).toBeInTheDocument(); + }); + }); + + describe("annotation", () => { + it("should render annotation in content area", () => { + render( + Annotation}> + Content + + ); + + expect(screen.getByTestId("annotation")).toBeInTheDocument(); + }); + }); + + describe("description", () => { + it("should render info icon when description is provided", () => { + const { container } = render( + + Content + + ); + + expect(container.querySelector(".dmapp--text-info")).toBeInTheDocument(); + }); + + it("should not render info icon when description is not provided", () => { + const { container } = render(Content); + + expect(container.querySelector(".dmapp--text-info")).not.toBeInTheDocument(); + }); + }); + + describe("group divider", () => { + it("should render divider by default", () => { + const { container } = render( + {}}> + Content + + ); + + expect(container.querySelector(".eccgui-separation__divider-horizontal")).toBeInTheDocument(); + }); + + it("should hide divider when hideGroupDivider is true", () => { + const { container } = render( + {}} hideGroupDivider={true}> + Content + + ); + + expect(container.querySelector(".eccgui-separation__divider-horizontal")).not.toBeInTheDocument(); + }); + }); + + describe("content props", () => { + it("should apply contentProps to content container", () => { + const { container } = render( + + Content + + ); + + expect(container.querySelector(".custom-content-class")).toBeInTheDocument(); + }); + }); + + describe("ReactElement as title", () => { + it("should render ReactElement title", () => { + render( + Custom Title Element}> + Content + + ); + + expect(screen.getByTestId("custom-title")).toBeInTheDocument(); + expect(screen.getByText("Custom Title Element")).toBeInTheDocument(); + }); + }); + + describe("custom styles", () => { + it("should apply custom style prop", () => { + const { container } = render( + + Content + + ); + + const section = container.querySelector(".eccgui-contentgroup") as HTMLElement; + expect(section.style.backgroundColor).toBe("red"); + }); + + it("should merge custom style with gradient border style", () => { + const { container } = render( + + Content + + ); + + const section = container.querySelector(".eccgui-contentgroup") as HTMLElement; + expect(section.style.backgroundColor).toBe("red"); + expect(section.style.getPropertyValue("--eccgui-color-contentgroup-border-sub")).toBeTruthy(); + }); + }); +});